Skip to content

Commit 7141ea6

Browse files
pmbrullclaude
andcommitted
Fixes 4362: preserve secret: prefix during Python SDK serialization (#28625)
CustomSecretStr serialization resolved `secret:<id>` external references through the configured secrets manager, stripping the `secret:` prefix from model_dump / model_dump_json output. On create/update the SDK then sent the resolved (or, with the default DB manager, prefix-stripped) value to the server, so external references were stored as plain secrets instead of being preserved. handle_secret now serializes with skip_secret_manager=True so the raw stored reference survives transport. Secret resolution still happens at use-time via the direct get_secret_value() call when building a connection, so ingestion keeps resolving secret:<id> as before. Fixes open-metadata/openmetadata-collate#4362 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f329dd4 commit 7141ea6

2 files changed

Lines changed: 133 additions & 3 deletions

File tree

ingestion/src/metadata/ingestion/models/custom_pydantic.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,15 @@ def handle_secret(value: Any, handler, info: SerializationInfo) -> str:
205205
Handle the secret value in the model.
206206
"""
207207
if not (info.context is not None and info.context.get("mask_secrets", False)):
208+
# Serialization must preserve the raw stored value. For external secret
209+
# references (`secret:<id>`) this keeps the reference intact instead of
210+
# resolving it, so the payload sent to the server keeps the reference and
211+
# is not silently turned into a plain secret. Resolution against the
212+
# secrets manager happens at use-time through a direct get_secret_value()
213+
# call (e.g. when building a connection).
208214
if info.mode == "json":
209-
# short circuit the json serialization and return the actual value
210-
return value.get_secret_value()
211-
return handler(value.get_secret_value())
215+
return value.get_secret_value(skip_secret_manager=True)
216+
return handler(value.get_secret_value(skip_secret_manager=True))
212217
return str(value) # use pydantic's logic to mask the secret
213218

214219

ingestion/tests/unit/models/test_custom_pydantic.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import json
12
import uuid
23
from typing import List, Optional
34
from unittest import TestCase
45

6+
import pytest
7+
58
from metadata.generated.schema.api.data.createDashboardDataModel import (
69
CreateDashboardDataModelRequest,
710
)
@@ -21,14 +24,30 @@
2124
TableConstraint,
2225
TableType,
2326
)
27+
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
28+
BasicAuth,
29+
)
30+
from metadata.generated.schema.entity.services.connections.database.mysqlConnection import (
31+
MysqlConnection,
32+
)
33+
from metadata.generated.schema.security.secrets.secretsManagerClientLoader import (
34+
SecretsManagerClientLoader,
35+
)
36+
from metadata.generated.schema.security.secrets.secretsManagerProvider import (
37+
SecretsManagerProvider,
38+
)
2439
from metadata.generated.schema.type.basic import (
2540
EntityExtension,
2641
EntityName,
2742
FullyQualifiedEntityName,
2843
Markdown,
2944
)
3045
from metadata.generated.schema.type.entityReference import EntityReference
46+
from metadata.ingestion.connections.builders import get_password_secret
3147
from metadata.ingestion.models.custom_pydantic import BaseModel, CustomSecretStr
48+
from metadata.utils.secrets.secrets_manager import SecretsManager
49+
from metadata.utils.secrets.secrets_manager_factory import SecretsManagerFactory
50+
from metadata.utils.singleton import Singleton
3251

3352

3453
class CustomPydanticValidationTest(TestCase):
@@ -837,6 +856,112 @@ class ComplexSecretModel(BaseModel):
837856
)
838857

839858

859+
class TestExternalSecretReferenceSerialization:
860+
"""Regression tests for https://github.com/open-metadata/openmetadata-collate/issues/4362.
861+
862+
Values prefixed with ``secret:`` are external secret references: the server
863+
resolves them against an external secret manager instead of persisting the
864+
raw value. The prefix MUST survive SDK serialization. If it is stripped, the
865+
reference is sent as a plain secret and ends up stored in Collate's internal
866+
secret manager rather than being resolved externally.
867+
868+
The SDK serializes create/update payloads with
869+
``model_dump_json(context={"mask_secrets": False})`` (see the PUT path in
870+
``metadata.ingestion.ometa.ometa_api``), so these tests exercise that exact
871+
serialization with the default ``DBSecretsManager`` active, as it is once an
872+
ometa client has been instantiated.
873+
"""
874+
875+
EXTERNAL_SECRET_REFERENCE = "secret:/external/path/to/db/password"
876+
877+
@pytest.fixture(autouse=True)
878+
def db_secrets_manager(self):
879+
Singleton.clear_all()
880+
SecretsManagerFactory(SecretsManagerProvider.db, SecretsManagerClientLoader.noop)
881+
yield
882+
Singleton.clear_all()
883+
884+
def test_prefix_preserved_for_custom_secret_str(self):
885+
class Connection(BaseModel):
886+
password: CustomSecretStr
887+
888+
connection = Connection(password=self.EXTERNAL_SECRET_REFERENCE)
889+
890+
serialized = json.loads(connection.model_dump_json(context={"mask_secrets": False}))
891+
892+
assert serialized["password"] == self.EXTERNAL_SECRET_REFERENCE
893+
894+
def test_prefix_preserved_for_typed_service_connection(self):
895+
connection = MysqlConnection(
896+
username="user",
897+
authType=BasicAuth(password=self.EXTERNAL_SECRET_REFERENCE),
898+
hostPort="localhost:3306",
899+
)
900+
901+
serialized = json.loads(connection.model_dump_json(context={"mask_secrets": False}, by_alias=True))
902+
903+
assert serialized["authType"]["password"] == self.EXTERNAL_SECRET_REFERENCE
904+
905+
906+
RESOLVED_EXTERNAL_SECRET = "resolved-external-password"
907+
908+
909+
class _FakeExternalSecretsManager(SecretsManager):
910+
"""Test double for an external secrets manager (e.g. AWS, Azure).
911+
912+
Resolves any reference id to a fixed value, so a test can tell real
913+
resolution apart from mere ``secret:`` prefix stripping.
914+
"""
915+
916+
def get_string_value(self, secret_id: str) -> str:
917+
return RESOLVED_EXTERNAL_SECRET
918+
919+
920+
class TestExternalSecretReferenceResolution:
921+
"""Guards the connect-time resolution path against the #4362 fix.
922+
923+
Preserving the ``secret:`` reference during serialization must NOT change how
924+
ingestion resolves that reference when building a connection. Resolution runs
925+
through a direct ``get_secret_value()`` call (see
926+
``metadata.ingestion.connections.builders.get_password_secret``), not through
927+
serialization, so it stays intact. An external secrets manager is active
928+
here, as it would be on a hybrid ingestion runner.
929+
"""
930+
931+
EXTERNAL_SECRET_REFERENCE = "secret:/external/path/to/db/password"
932+
933+
@pytest.fixture(autouse=True)
934+
def external_secrets_manager(self):
935+
Singleton.clear_all()
936+
factory = SecretsManagerFactory(SecretsManagerProvider.db, SecretsManagerClientLoader.noop)
937+
factory.secrets_manager = _FakeExternalSecretsManager()
938+
yield
939+
Singleton.clear_all()
940+
941+
def test_reference_resolves_for_connection_use(self):
942+
connection = MysqlConnection(
943+
username="user",
944+
authType=BasicAuth(password=self.EXTERNAL_SECRET_REFERENCE),
945+
hostPort="localhost:3306",
946+
)
947+
948+
password = get_password_secret(connection)
949+
950+
assert password.get_secret_value() == RESOLVED_EXTERNAL_SECRET
951+
952+
def test_reference_is_not_resolved_into_serialized_payload(self):
953+
connection = MysqlConnection(
954+
username="user",
955+
authType=BasicAuth(password=self.EXTERNAL_SECRET_REFERENCE),
956+
hostPort="localhost:3306",
957+
)
958+
959+
serialized = json.loads(connection.model_dump_json(context={"mask_secrets": False}, by_alias=True))
960+
961+
assert serialized["authType"]["password"] == self.EXTERNAL_SECRET_REFERENCE
962+
assert serialized["authType"]["password"] != RESOLVED_EXTERNAL_SECRET
963+
964+
840965
class DashboardDataModelTransformationTest(TestCase):
841966
"""Test DashboardDataModel transformations with nested children and reserved keywords."""
842967

0 commit comments

Comments
 (0)