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