This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2740c33fcc2 Allow secrets backend kwargs to be set via per-key env 
vars (#63312)
2740c33fcc2 is described below

commit 2740c33fcc2b820e2485915d45e7d2e7552bbe6c
Author: Shubham Gondane <[email protected]>
AuthorDate: Wed Mar 11 17:28:55 2026 -0700

    Allow secrets backend kwargs to be set via per-key env vars (#63312)
    
    * Allow secrets backend kwargs to be set via per-key env vars
    
    Adds support for AIRFLOW__SECRETS__BACKEND_KWARG__<KEY> environment 
variables
    as an alternative to the single AIRFLOW__SECRETS__BACKEND_KWARGS JSON blob.
    
    * Rename newsfragment to match PR number
    
    * Mask per-key backend kwarg env vars in logs at startup
    
    * ci: retrigger CI
---
 .../security/secrets/secrets-backend/index.rst     | 23 +++++++
 airflow-core/newsfragments/63312.feature.rst       |  1 +
 .../src/airflow/config_templates/config.yml        |  8 +++
 airflow-core/src/airflow/configuration.py          | 12 ++++
 airflow-core/tests/unit/always/test_secrets.py     | 71 ++++++++++++++++++++++
 airflow-core/tests/unit/core/test_configuration.py | 56 +++++++++++++++++
 .../src/airflow_shared/configuration/parser.py     | 32 ++++++++++
 7 files changed, 203 insertions(+)

diff --git a/airflow-core/docs/security/secrets/secrets-backend/index.rst 
b/airflow-core/docs/security/secrets/secrets-backend/index.rst
index a4ae1547a10..029dadcfb87 100644
--- a/airflow-core/docs/security/secrets/secrets-backend/index.rst
+++ b/airflow-core/docs/security/secrets/secrets-backend/index.rst
@@ -78,6 +78,29 @@ the example below.
     $ airflow config get-value secrets backend
     
airflow.providers.google.cloud.secrets.secret_manager.CloudSecretManagerBackend
 
+Setting individual backend kwargs
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Instead of encoding all kwargs as a JSON blob, you can set each one as a 
separate environment
+variable using the ``AIRFLOW__SECRETS__BACKEND_KWARG__<KEY>`` prefix:
+
+.. code-block:: bash
+
+    # These two are equivalent:
+    export AIRFLOW__SECRETS__BACKEND_KWARGS='{"role_id": "abc", "secret_id": 
"xyz"}'
+
+    # or individually (useful for K8s Secrets):
+    export AIRFLOW__SECRETS__BACKEND_KWARG__ROLE_ID=abc
+    export AIRFLOW__SECRETS__BACKEND_KWARG__SECRET_ID=xyz
+
+Per-key variables override the same key from ``BACKEND_KWARGS``. Values are 
raw strings
+(not JSON-parsed). For workers, use the
+``AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__<KEY>`` prefix.
+
+.. note::
+   These environment variables are masked in logs at startup, the same way
+   ``BACKEND_KWARGS`` is masked.
+
 Worker Specific Configuration
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/airflow-core/newsfragments/63312.feature.rst 
b/airflow-core/newsfragments/63312.feature.rst
new file mode 100644
index 00000000000..1af30e04ded
--- /dev/null
+++ b/airflow-core/newsfragments/63312.feature.rst
@@ -0,0 +1 @@
+Allow individual secrets backend kwargs to be set via 
``AIRFLOW__SECRETS__BACKEND_KWARG__<KEY>`` environment variables
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index d398f54ddfd..f931fedc473 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1460,6 +1460,10 @@ secrets:
 
         Example for AWS Systems Manager ParameterStore:
         ``{"connections_prefix": "/airflow/connections", "profile_name": 
"default"}``
+
+        You can also set individual kwargs via 
``AIRFLOW__SECRETS__BACKEND_KWARG__<KEY>=value``
+        environment variables. Per-key variables override the same key in this 
JSON setting.
+        Values are raw strings (not JSON-parsed).
       version_added: 1.10.10
       type: string
       sensitive: true
@@ -1820,6 +1824,10 @@ workers:
 
         Example for AWS Systems Manager ParameterStore:
         ``{"connections_prefix": "/airflow/connections", "profile_name": 
"default"}``
+
+        You can also set individual kwargs via 
``AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__<KEY>=value``
+        environment variables. Per-key variables override the same key in this 
JSON setting.
+        Values are raw strings (not JSON-parsed).
       version_added: 3.0.0
       type: string
       sensitive: true
diff --git a/airflow-core/src/airflow/configuration.py 
b/airflow-core/src/airflow/configuration.py
index 556821d71fb..69d936be01d 100644
--- a/airflow-core/src/airflow/configuration.py
+++ b/airflow-core/src/airflow/configuration.py
@@ -491,6 +491,7 @@ class AirflowConfigParser(_SharedAirflowConfigParser):
         )
 
     def mask_secrets(self):
+        from airflow._shared.configuration.parser import 
_build_kwarg_env_prefix, _collect_kwarg_env_vars
         from airflow._shared.secrets_masker import mask_secret as 
mask_secret_core
         from airflow.sdk.log import mask_secret as mask_secret_sdk
 
@@ -508,6 +509,17 @@ class AirflowConfigParser(_SharedAirflowConfigParser):
             mask_secret_core(value)
             mask_secret_sdk(value)
 
+        # Mask per-key backend kwarg env vars 
(AIRFLOW__SECRETS__BACKEND_KWARG__* etc.).
+        # These are not in sensitive_config_values but may contain sensitive 
values.
+        for _section, _kwargs_key in [
+            ("secrets", "backend_kwargs"),
+            ("workers", "secrets_backend_kwargs"),
+        ]:
+            _prefix = _build_kwarg_env_prefix(_section, _kwargs_key)
+            for _value in _collect_kwarg_env_vars(_prefix).values():
+                mask_secret_core(_value)
+                mask_secret_sdk(_value)
+
     def load_test_config(self):
         """
         Use test configuration rather than the configuration coming from 
airflow defaults.
diff --git a/airflow-core/tests/unit/always/test_secrets.py 
b/airflow-core/tests/unit/always/test_secrets.py
index dfa39352b92..d09f04df7df 100644
--- a/airflow-core/tests/unit/always/test_secrets.py
+++ b/airflow-core/tests/unit/always/test_secrets.py
@@ -225,3 +225,74 @@ class TestVariableFromSecrets:
     )
     def test_variable_env_var_do_not_access_team_specific(self):
         assert Variable.get_variable_from_secrets(key="_team___myvar") is None
+
+
+@skip_if_force_lowest_dependencies_marker
+class TestSecretBackendKwargEnvVars:
+    """Test per-key env var overrides for secrets backend kwargs."""
+
+    def setup_method(self) -> None:
+        SecretCache.reset()
+
+    @conf_vars(
+        {
+            (
+                "secrets",
+                "backend",
+            ): 
"airflow.providers.amazon.aws.secrets.systems_manager.SystemsManagerParameterStoreBackend",
+        }
+    )
+    @mock.patch.dict(
+        "os.environ",
+        {"AIRFLOW__SECRETS__BACKEND_KWARG__CONNECTIONS_PREFIX": 
"/airflow/connections"},
+    )
+    def test_backend_kwarg_env_vars_basic(self):
+        """Per-key env var is picked up when no JSON blob is set."""
+        backends = initialize_secrets_backends()
+        systems_manager = next(
+            b for b in backends if b.__class__.__name__ == 
"SystemsManagerParameterStoreBackend"
+        )
+        assert systems_manager.connections_prefix == "/airflow/connections"
+
+    @conf_vars(
+        {
+            (
+                "secrets",
+                "backend",
+            ): 
"airflow.providers.amazon.aws.secrets.systems_manager.SystemsManagerParameterStoreBackend",
+            ("secrets", "backend_kwargs"): '{"connections_prefix": "/old"}',
+        }
+    )
+    @mock.patch.dict(
+        "os.environ",
+        {"AIRFLOW__SECRETS__BACKEND_KWARG__CONNECTIONS_PREFIX": "/new"},
+    )
+    def test_backend_kwarg_env_vars_override_json(self):
+        """Per-key env var overrides the same key in the JSON blob."""
+        backends = initialize_secrets_backends()
+        systems_manager = next(
+            b for b in backends if b.__class__.__name__ == 
"SystemsManagerParameterStoreBackend"
+        )
+        assert systems_manager.connections_prefix == "/new"
+
+    @conf_vars(
+        {
+            (
+                "secrets",
+                "backend",
+            ): 
"airflow.providers.amazon.aws.secrets.systems_manager.SystemsManagerParameterStoreBackend",
+            ("secrets", "backend_kwargs"): '{"connections_prefix": 
"/airflow"}',
+        }
+    )
+    @mock.patch.dict(
+        "os.environ",
+        {"AIRFLOW__SECRETS__BACKEND_KWARG__VARIABLES_PREFIX": 
"/airflow/variables"},
+    )
+    def test_backend_kwarg_env_vars_merge_with_json(self):
+        """Per-key env var is merged with (not replacing) the JSON blob."""
+        backends = initialize_secrets_backends()
+        systems_manager = next(
+            b for b in backends if b.__class__.__name__ == 
"SystemsManagerParameterStoreBackend"
+        )
+        assert systems_manager.connections_prefix == "/airflow"
+        assert systems_manager.variables_prefix == "/airflow/variables"
diff --git a/airflow-core/tests/unit/core/test_configuration.py 
b/airflow-core/tests/unit/core/test_configuration.py
index e591421e68b..be0f191f8c9 100644
--- a/airflow-core/tests/unit/core/test_configuration.py
+++ b/airflow-core/tests/unit/core/test_configuration.py
@@ -924,6 +924,62 @@ class TestConf:
             for key, value in expected_backend_kwargs.items():
                 assert getattr(secrets_backend, key) == value
 
+    def test_build_kwarg_env_prefix(self):
+        """Test that _build_kwarg_env_prefix generates the correct prefixes."""
+        from airflow._shared.configuration.parser import 
_build_kwarg_env_prefix
+
+        assert _build_kwarg_env_prefix("secrets", "backend_kwargs") == 
"AIRFLOW__SECRETS__BACKEND_KWARG__"
+        assert (
+            _build_kwarg_env_prefix("workers", "secrets_backend_kwargs")
+            == "AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__"
+        )
+
+    @mock.patch.dict(
+        "os.environ",
+        {
+            "AIRFLOW__SECRETS__BACKEND_KWARG__ROLE_ID": "abc",
+            "AIRFLOW__SECRETS__BACKEND_KWARG__": "ignored",  # empty key — 
must be ignored
+            "OTHER_VAR": "irrelevant",
+        },
+    )
+    def test_collect_kwarg_env_vars(self):
+        """Test that _collect_kwarg_env_vars collects matching vars and 
ignores empty keys."""
+        from airflow._shared.configuration.parser import 
_collect_kwarg_env_vars
+
+        result = _collect_kwarg_env_vars("AIRFLOW__SECRETS__BACKEND_KWARG__")
+        assert result == {"role_id": "abc"}
+
+    @conf_vars(
+        {
+            (
+                "workers",
+                "secrets_backend",
+            ): 
"airflow.providers.amazon.aws.secrets.systems_manager.SystemsManagerParameterStoreBackend",
+        }
+    )
+    @mock.patch.dict(
+        "os.environ",
+        {"AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__CONNECTIONS_PREFIX": 
"/worker/connections"},
+    )
+    def test_worker_backend_kwarg_env_vars(self):
+        """Per-key env var is picked up for the workers secrets backend."""
+        backends = ensure_secrets_loaded(DEFAULT_SECRETS_SEARCH_PATH_WORKERS)
+        secrets_backend = backends[0]
+        assert secrets_backend.__class__.__name__ == 
"SystemsManagerParameterStoreBackend"
+        assert secrets_backend.connections_prefix == "/worker/connections"
+
+    @mock.patch("airflow._shared.secrets_masker.mask_secret")
+    @mock.patch("airflow.sdk.log.mask_secret")
+    @mock.patch.dict(
+        "os.environ",
+        {"AIRFLOW__SECRETS__BACKEND_KWARG__ROLE_ID": "super-secret-role"},
+    )
+    def test_mask_secrets_includes_backend_kwarg_env_vars(self, mock_sdk_mask, 
mock_core_mask):
+        """Per-key BACKEND_KWARG__* env var values are registered with the 
masker at startup."""
+        conf.mask_secrets()
+        all_core_masked = [call.args[0] for call in 
mock_core_mask.call_args_list]
+        assert "super-secret-role" in all_core_masked
+
     def test_lookup_sequence_override_excludes_env_vars(self, monkeypatch):
         """Test that overriding lookup sequence to exclude env vars means env 
vars are not respected."""
 
diff --git a/shared/configuration/src/airflow_shared/configuration/parser.py 
b/shared/configuration/src/airflow_shared/configuration/parser.py
index 455a6282924..b06f9611a98 100644
--- a/shared/configuration/src/airflow_shared/configuration/parser.py
+++ b/shared/configuration/src/airflow_shared/configuration/parser.py
@@ -43,6 +43,34 @@ from .exceptions import AirflowConfigException
 log = logging.getLogger(__name__)
 
 
+def _build_kwarg_env_prefix(section: str, kwargs_key: str) -> str:
+    """
+    Build env prefix for per-key backend kwargs.
+
+    ("secrets",  "backend_kwargs")         -> 
"AIRFLOW__SECRETS__BACKEND_KWARG__"
+    ("workers",  "secrets_backend_kwargs") -> 
"AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__"
+    """
+    singular_key = kwargs_key.replace("_kwargs", "_kwarg")
+    return f"{ENV_VAR_PREFIX}{section.upper()}__{singular_key.upper()}__"
+
+
+def _collect_kwarg_env_vars(prefix: str) -> dict[str, str]:
+    """
+    Scan os.environ for per-key secrets backend kwargs.
+
+    AIRFLOW__SECRETS__BACKEND_KWARG__ROLE_ID -> {"role_id": value}
+    Values are raw strings (not JSON-parsed).
+    Empty keys (trailing __ with no suffix) are ignored.
+    """
+    overrides: dict[str, str] = {}
+    for env_var, value in os.environ.items():
+        if env_var.startswith(prefix):
+            kwarg_key = env_var[len(prefix) :].lower()
+            if kwarg_key:
+                overrides[kwarg_key] = value
+    return overrides
+
+
 ConfigType = str | int | float | bool
 ConfigOptionsDictType = dict[str, ConfigType]
 ConfigSectionSourcesType = dict[str, str | tuple[str, str]]
@@ -409,6 +437,10 @@ class AirflowConfigParser(ConfigParser):
             log.warning("Failed to parse [%s] %s into a dict, defaulting to no 
kwargs.", section, kwargs_key)
             backend_kwargs = {}
 
+        # Collect per-key overrides; they take precedence over the JSON blob.
+        env_prefix = _build_kwarg_env_prefix(section, kwargs_key)
+        backend_kwargs.update(_collect_kwarg_env_vars(env_prefix))
+
         return secrets_backend_cls(**backend_kwargs)
 
     def _get_config_value_from_secret_backend(self, config_key: str) -> str | 
None:

Reply via email to