|
| 1 | +import json |
1 | 2 | import uuid |
2 | 3 | from typing import List, Optional |
3 | 4 | from unittest import TestCase |
4 | 5 |
|
| 6 | +import pytest |
| 7 | + |
5 | 8 | from metadata.generated.schema.api.data.createDashboardDataModel import ( |
6 | 9 | CreateDashboardDataModelRequest, |
7 | 10 | ) |
|
21 | 24 | TableConstraint, |
22 | 25 | TableType, |
23 | 26 | ) |
| 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 | +) |
24 | 39 | from metadata.generated.schema.type.basic import ( |
25 | 40 | EntityExtension, |
26 | 41 | EntityName, |
27 | 42 | FullyQualifiedEntityName, |
28 | 43 | Markdown, |
29 | 44 | ) |
30 | 45 | from metadata.generated.schema.type.entityReference import EntityReference |
| 46 | +from metadata.ingestion.connections.builders import get_password_secret |
31 | 47 | 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 |
32 | 51 |
|
33 | 52 |
|
34 | 53 | class CustomPydanticValidationTest(TestCase): |
@@ -837,6 +856,112 @@ class ComplexSecretModel(BaseModel): |
837 | 856 | ) |
838 | 857 |
|
839 | 858 |
|
| 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 | + |
840 | 965 | class DashboardDataModelTransformationTest(TestCase): |
841 | 966 | """Test DashboardDataModel transformations with nested children and reserved keywords.""" |
842 | 967 |
|
|
0 commit comments