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 f0f978d2736 Mask per-key secrets-backend-kwarg overrides on the Config 
API (#67622)
f0f978d2736 is described below

commit f0f978d2736891a2f9e9d2954e87fc358e1ef4e3
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu May 28 01:47:09 2026 +0200

    Mask per-key secrets-backend-kwarg overrides on the Config API (#67622)
    
    Per-key environment-variable overrides like 
`AIRFLOW__SECRETS__BACKEND_KWARG__SECRET_ID` and 
`AIRFLOW__WORKERS__SECRETS_BACKEND_KWARG__SECRET_ID` are materialised by 
`conf.as_dict` as synthetic options under the `secrets` and `workers` sections 
(e.g. `backend_kwarg__secret_id`). These synthetic options carry the same Vault 
/ role_id / secret_id material as the registered `backend_kwargs` option, but 
they are not present in `conf.sensitive_config_values`, so the Config API was 
returnin [...]
    
    This change adds:
    
    - a constant `_PER_KEY_SENSITIVE_PREFIXES` that names the two 
synthetic-option prefixes,
    - a helper `_mask_per_key_sensitive_options` that the `GET /config` route 
calls when `display_sensitive=False`,
    - a helper `_is_per_key_sensitive_option` that extends the sensitivity 
check in the `GET /config/section/{section}/option/{option}` route.
    
    Reference: airflow-s/airflow-s#433
    
    Generated-by: Claude Opus 4.7 (1M context) following the guidelines at 
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
---
 .../api_fastapi/core_api/routes/public/config.py   |  9 ++-
 .../api_fastapi/core_api/services/public/config.py | 32 +++++++++++
 .../core_api/routes/public/test_config.py          | 67 ++++++++++++++++++++++
 3 files changed, 107 insertions(+), 1 deletion(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/config.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/config.py
index 784d652c155..9f4b3359508 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/config.py
@@ -32,6 +32,8 @@ from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_
 from airflow.api_fastapi.core_api.security import requires_access_configuration
 from airflow.api_fastapi.core_api.services.public.config import (
     _check_expose_config,
+    _is_per_key_sensitive_option,
+    _mask_per_key_sensitive_options,
     _response_based_on_accept,
 )
 from airflow.configuration import conf
@@ -101,6 +103,8 @@ def get_config(
             detail=f"Section {section} not found.",
         )
     conf_dict = conf.as_dict(display_source=False, 
display_sensitive=display_sensitive)
+    if not display_sensitive:
+        _mask_per_key_sensitive_options(conf_dict)
 
     if section:
         conf_section_value = conf_dict[section]
@@ -148,7 +152,10 @@ def get_config_value(
             detail=f"Option [{section}/{option}] not found.",
         )
 
-    if (section.lower(), option.lower()) in conf.sensitive_config_values:
+    section_l, option_l = section.lower(), option.lower()
+    if (section_l, option_l) in conf.sensitive_config_values or 
_is_per_key_sensitive_option(
+        section_l, option_l
+    ):
         value = "< hidden >"
     else:
         value = conf.get(section, option)
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/config.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/config.py
index 0b51a6c33ae..d805827d8d6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/config.py
@@ -25,6 +25,38 @@ from airflow.api_fastapi.common.types import Mimetype
 from airflow.api_fastapi.core_api.datamodels.config import Config
 from airflow.configuration import conf
 
+# Per-key environment-variable overrides for secrets-backend kwargs are
+# surfaced by ``conf.as_dict`` as synthetic options under the ``secrets``
+# and ``workers`` sections. They carry the same secrets-backend material
+# (e.g. Vault role_id / secret_id) as the registered ``backend_kwargs``
+# option, so they need the same redaction treatment when
+# ``display_sensitive=False``.
+_PER_KEY_SENSITIVE_PREFIXES: dict[str, str] = {
+    "secrets": "backend_kwarg__",
+    "workers": "secrets_backend_kwarg__",
+}
+
+
+def _is_per_key_sensitive_option(section: str, option: str) -> bool:
+    """Return True for synthetic per-key secrets-backend-kwarg options."""
+    prefix = _PER_KEY_SENSITIVE_PREFIXES.get(section)
+    return prefix is not None and option.startswith(prefix)
+
+
+def _mask_per_key_sensitive_options(conf_dict: dict) -> None:
+    """Mask synthetic per-key secrets-backend-kwarg options in-place."""
+    for section, prefix in _PER_KEY_SENSITIVE_PREFIXES.items():
+        options = conf_dict.get(section)
+        if not options:
+            continue
+        for option in list(options):
+            if option.startswith(prefix):
+                current = options[option]
+                if isinstance(current, tuple):
+                    options[option] = ("< hidden >", current[1])
+                else:
+                    options[option] = "< hidden >"
+
 
 def _check_expose_config() -> bool:
     display_sensitive: bool | None = None
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_config.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_config.py
index aa1a74890f7..cebd1cf1211 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_config.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_config.py
@@ -492,3 +492,70 @@ class TestGetConfigValue(TestConfigEndpoint):
             
f"/config/section/{SECTION_DATABASE}/option/{OPTION_KEY_SQL_ALCHEMY_CONN}"
         )
         assert response.status_code == 403
+
+
+SECTION_SECRETS = "secrets"
+SECTION_WORKERS = "workers"
+PER_KEY_OPTION_SECRETS = "backend_kwarg__secret_id"
+PER_KEY_OPTION_WORKERS = "secrets_backend_kwarg__secret_id"
+PER_KEY_VALUE = "vault-role-id-or-secret-id-material"
+
+
+class TestPerKeyBackendKwargMasking(TestConfigEndpoint):
+    """Synthetic per-key secrets-backend-kwarg options (e.g.
+    ``AIRFLOW__SECRETS__BACKEND_KWARG__SECRET_ID``) materialised by
+    ``conf.as_dict`` carry the same Vault / role_id / secret_id material as
+    the registered ``backend_kwargs`` option. The Config API must redact them
+    on the way out when ``display_sensitive=False``."""
+
+    @pytest.fixture(autouse=True)
+    def setup_per_key(self) -> Generator[None, None, None]:
+        per_key_dict = {
+            SECTION_CORE: {OPTION_KEY_PARALLELISM: OPTION_VALUE_PARALLELISM},
+            SECTION_SECRETS: {PER_KEY_OPTION_SECRETS: PER_KEY_VALUE},
+            SECTION_WORKERS: {PER_KEY_OPTION_WORKERS: PER_KEY_VALUE},
+        }
+
+        def _mock_conf_as_dict(display_sensitive: bool, **_):
+            return {section: options.copy() for section, options in 
per_key_dict.items()}
+
+        def _mock_has_option(section: str, option: str) -> bool:
+            return option in per_key_dict.get(section, {})
+
+        with (
+            conf_vars(AIRFLOW_CONFIG_ENABLE_EXPOSE_CONFIG),
+            patch(
+                
"airflow.api_fastapi.core_api.routes.public.config.conf.as_dict",
+                new=_mock_conf_as_dict,
+            ),
+            patch(
+                
"airflow.api_fastapi.core_api.routes.public.config.conf.has_option",
+                new=_mock_has_option,
+            ),
+        ):
+            yield
+
+    def test_get_config_masks_per_key_secrets_backend_kwargs(self, 
test_client):
+        """``GET /config`` must redact synthetic per-key options under both
+        the ``secrets`` and ``workers`` sections when
+        ``display_sensitive=False`` (the API-server default)."""
+        response = test_client.get("/config", headers=HEADERS_JSON)
+        assert response.status_code == 200
+
+        sections = {
+            s["name"]: {o["key"]: o["value"] for o in s["options"]} for s in 
response.json()["sections"]
+        }
+        assert sections[SECTION_SECRETS][PER_KEY_OPTION_SECRETS] == 
OPTION_VALUE_SENSITIVE_HIDDEN
+        assert sections[SECTION_WORKERS][PER_KEY_OPTION_WORKERS] == 
OPTION_VALUE_SENSITIVE_HIDDEN
+        # Non-sensitive option in the same response must remain untouched.
+        assert sections[SECTION_CORE][OPTION_KEY_PARALLELISM] == 
OPTION_VALUE_PARALLELISM
+
+    def test_get_config_value_masks_per_key_secrets_backend_kwarg(self, 
test_client):
+        """``GET /config/section/{section}/option/{option}`` must redact a
+        per-key synthetic option the same way the section-dump does."""
+        response = test_client.get(
+            
f"/config/section/{SECTION_SECRETS}/option/{PER_KEY_OPTION_SECRETS}",
+            headers=HEADERS_JSON,
+        )
+        assert response.status_code == 200
+        assert response.json()["sections"][0]["options"][0]["value"] == 
OPTION_VALUE_SENSITIVE_HIDDEN

Reply via email to