From 21ca4844c0365e3e81225b531b6b8950c1240d33 Mon Sep 17 00:00:00 2001 From: HootRock Date: Tue, 20 Oct 2026 23:12:25 +0800 Subject: [PATCH] fix(secrets): resolve references in configuration lists --- airbyte/secrets/hydration.py | 18 ++--- tests/unit_tests/test_secrets_hydration.py | 88 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/test_secrets_hydration.py diff --git a/airbyte/secrets/hydration.py b/airbyte/secrets/hydration.py index 9945da550..75b1bb2ac 100644 --- a/airbyte/secrets/hydration.py +++ b/airbyte/secrets/hydration.py @@ -18,23 +18,19 @@ def _hydrate_recursive( - config: dict, + config: dict | list, ) -> None: - """Recursively hydrate secrets in the given configuration dictionary. + """Recursively hydrate secrets in a configuration dictionary or list. Args: - config (dict): The configuration dictionary to hydrate. + config (dict | list): The configuration container to hydrate. """ - for key, value in config.items(): - if isinstance(value, dict): + items = config.items() if isinstance(config, dict) else enumerate(config) + for key, value in items: + if isinstance(value, (dict, list)): _hydrate_recursive(value) - elif isinstance(value, list): - for item in value: - if isinstance(item, dict): - _hydrate_recursive(item) elif isinstance(value, str) and value.startswith(SECRETS_HYDRATION_PREFIX): - # Assuming the value is a secret reference - config[key] = get_secret(value) # Replace with actual secret fetching logic + config[key] = get_secret(value) def hydrate_secrets( diff --git a/tests/unit_tests/test_secrets_hydration.py b/tests/unit_tests/test_secrets_hydration.py new file mode 100644 index 000000000..fbe8c6f52 --- /dev/null +++ b/tests/unit_tests/test_secrets_hydration.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Tests for recursive secret hydration in connector configurations.""" + +from copy import deepcopy + +import pytest + +from airbyte.exceptions import PyAirbyteSecretNotFoundError +from airbyte.secrets import config as secrets_config +from airbyte.secrets.base import SecretString +from airbyte.secrets.env_vars import EnvVarSecretManager +from airbyte.secrets.hydration import hydrate_secrets + + +@pytest.fixture(autouse=True) +def _env_secrets(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(secrets_config, "_SECRETS_SOURCES", [EnvVarSecretManager()]) + monkeypatch.setenv("PYAIRBYTE_HYDRATION_TEST_TOKEN", "offline-token") + monkeypatch.delenv("PYAIRBYTE_HYDRATION_TEST_MISSING", raising=False) + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ( + {"token": "secret_reference::PYAIRBYTE_HYDRATION_TEST_TOKEN"}, + {"token": "offline-token"}, + ), + ( + {"tokens": ["secret_reference::PYAIRBYTE_HYDRATION_TEST_TOKEN"]}, + {"tokens": ["offline-token"]}, + ), + ( + { + "tokens": [ + ["secret_reference::PYAIRBYTE_HYDRATION_TEST_TOKEN"], + { + "alternates": [ + "secret_reference::PYAIRBYTE_HYDRATION_TEST_TOKEN" + ] + }, + ], + }, + {"tokens": [["offline-token"], {"alternates": ["offline-token"]}]}, + ), + ], + ids=["dict-value", "list-value", "nested-lists-and-dicts"], +) +def test_hydrate_secrets_resolves_references_without_mutating_input( + config: dict, + expected: dict, +) -> None: + original = deepcopy(config) + + assert hydrate_secrets(config) == expected + assert config == original + + +def test_hydrate_secrets_masks_resolved_list_values() -> None: + result = hydrate_secrets({ + "tokens": ["secret_reference::PYAIRBYTE_HYDRATION_TEST_TOKEN"] + }) + + assert isinstance(result["tokens"][0], SecretString) + assert str(result["tokens"][0]) == "offline-token" + assert "offline-token" not in repr(result) + + +@pytest.mark.parametrize( + "config", + [ + {"token": "secret_reference::PYAIRBYTE_HYDRATION_TEST_MISSING"}, + {"tokens": ["secret_reference::PYAIRBYTE_HYDRATION_TEST_MISSING"]}, + {"tokens": [["secret_reference::PYAIRBYTE_HYDRATION_TEST_MISSING"]]}, + ], + ids=["dict-value", "list-value", "nested-list-value"], +) +def test_hydrate_secrets_rejects_missing_references(config: dict) -> None: + with pytest.raises(PyAirbyteSecretNotFoundError): + hydrate_secrets(config) + + +def test_hydrate_secrets_preserves_non_secret_list_values() -> None: + config = { + "values": [None, False, 0, 2, "ordinary", "a secret_reference::literal", [], {}] + } + + assert hydrate_secrets(config) == config