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 a1109f86030 Mask provider-declared sensitive connection extras on
every connection type (#73794)
a1109f86030 is described below
commit a1109f86030199a4b71aa34c9ff9edaa86f2e39a
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Oct 19 16:32:29 2026 +0200
Mask provider-declared sensitive connection extras on every connection type
(#73794)
* Mask provider-declared sensitive connection extras by connection type
The connections API masks ``extra`` values by field name only, so a
field a provider declares as a secret (``format: password`` in its
``conn-fields``) is returned in cleartext when its name is too generic
to be on the global list -- ``config`` is a secret for apprise and
harmless for other connection types. After the name-based masking, also
mask the fields the connection type's own provider declares sensitive,
read from provider metadata without importing hooks.
Generated-by: Claude Opus 5
* Add newsfragment
Generated-by: Claude Opus 5
* Restore provider-declared sensitive extras when saving a masked connection
Generated-by: Claude Opus 5
* Restore provider-sensitive extras only when the connection type is
unchanged
Generated-by: Claude Opus 5
* Keep provider-declared sensitive extras masked after a connection type
change
Generated-by: Claude Opus 5
* Describe masking of provider-declared sensitive extras across connection
types
Generated-by: Claude Opus 5
* Look up provider-declared sensitive extra keys in one set
Checking each key against every connection type's declared fields did the
same work on every key of every response; building the plain and prefixed names
once keeps the lookup a single membership test.
Generated-by: Claude Opus 5
---
airflow-core/newsfragments/73794.improvement.rst | 1 +
.../api_fastapi/core_api/datamodels/connections.py | 57 +++++++++
.../core_api/services/public/connections.py | 12 +-
.../core_api/datamodels/test_connections.py | 60 ++++++++++
.../core_api/routes/public/test_connections.py | 133 +++++++++++++++++++++
5 files changed, 261 insertions(+), 2 deletions(-)
diff --git a/airflow-core/newsfragments/73794.improvement.rst
b/airflow-core/newsfragments/73794.improvement.rst
new file mode 100644
index 00000000000..f408e652173
--- /dev/null
+++ b/airflow-core/newsfragments/73794.improvement.rst
@@ -0,0 +1 @@
+Mask connection ``extra`` fields that any installed provider declares as
secrets (``format: password`` in its connection form fields, for example
Apprise ``config``) in API responses, whatever the connection's type, in
addition to fields whose names match the global sensitive-name list. When a
client sends such a masked value back without changing the connection type, the
stored value is kept unchanged.
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/connections.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/connections.py
index 34cfe473348..f9fa3465d07 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/connections.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/connections.py
@@ -20,6 +20,7 @@ from __future__ import annotations
import json
from collections.abc import Iterable, Mapping
from datetime import datetime
+from functools import cache
from typing import Annotated, Any
from pydantic import Field, field_validator, model_validator
@@ -29,6 +30,48 @@ from airflow._shared.secrets_masker import redact,
should_hide_value_for_key
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel,
make_partial_model
from airflow.configuration import conf
+_MASK = "***"
+
+
+@cache
+def _sensitive_extra_keys() -> frozenset[str]:
+ """
+ Return the ``extra`` keys any provider declares sensitive, as plain and
``extra__<type>__`` names.
+
+ Providers mark a connection field as a secret with ``format: password`` in
their
+ ``conn-fields``. The name-based masking in ``redact`` cannot use that
knowledge, because it
+ only knows a fixed list of secret names, and ``config`` is not one of
them. Read from provider
+ metadata, without importing any hook.
+
+ Not limited to the connection's current type: after a ``conn_type`` change
the stored
+ ``extra`` still holds the previous type's secrets, and the new type does
not declare them.
+ """
+ from airflow.providers_manager import ProvidersManager
+
+ keys: set[str] = set()
+ for prefixed_name, widget in
ProvidersManager()._connection_form_widgets_from_metadata.items():
+ if widget.is_sensitive:
+ keys.update((widget.field_name, prefixed_name))
+ return frozenset(keys)
+
+
+def restore_provider_sensitive_extra(extra: Any, stored_extra: Any) -> Any:
+ """
+ Put back the stored value of provider-declared sensitive ``extra`` fields
submitted as the mask.
+
+ ``merge`` only restores keys on its fixed list of secret names, so the
keys masked by
+ ``ConnectionResponse.redact_provider_sensitive_extra`` would otherwise be
saved as the mask
+ when a client sends a response's ``extra`` back.
+ """
+ if not isinstance(extra, dict) or not isinstance(stored_extra, dict):
+ return extra
+ return {
+ key: stored_extra[key]
+ if value == _MASK and key in stored_extra and key in
_sensitive_extra_keys()
+ else value
+ for key, value in extra.items()
+ }
+
# Response Models
class ConnectionResponse(BaseModel):
@@ -52,6 +95,20 @@ class ConnectionResponse(BaseModel):
return None
return str(redact(v, field_info.field_name))
+ @model_validator(mode="after")
+ def redact_provider_sensitive_extra(self) -> ConnectionResponse:
+ """Mask the ``extra`` fields any provider declares sensitive."""
+ if not self.extra or not _sensitive_extra_keys():
+ return self
+ extra = json.loads(self.extra)
+ if not isinstance(extra, dict):
+ return self
+ for key, value in extra.items():
+ if key in _sensitive_extra_keys() and value not in (None, ""):
+ extra[key] = _MASK
+ self.extra = json.dumps(extra)
+ return self
+
@field_validator("extra", mode="before")
@classmethod
def redact_extra(cls, v: str | None) -> str | None:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/connections.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/connections.py
index 74f5d05df77..319d97204c3 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/connections.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/connections.py
@@ -32,7 +32,10 @@ from airflow.api_fastapi.core_api.datamodels.common import (
BulkDeleteAction,
BulkUpdateAction,
)
-from airflow.api_fastapi.core_api.datamodels.connections import ConnectionBody
+from airflow.api_fastapi.core_api.datamodels.connections import (
+ ConnectionBody,
+ restore_provider_sensitive_extra,
+)
from airflow.api_fastapi.core_api.services.public.common import BulkService
from airflow.models.connection import Connection
@@ -41,6 +44,7 @@ def update_orm_from_pydantic(
orm_conn: Connection, pydantic_conn: ConnectionBody, update_mask:
list[str] | None = None
) -> None:
"""Update ORM object from Pydantic object."""
+ stored_conn_type = orm_conn.conn_type
# Not all fields match and some need setters, therefore copy partly
manually via setters
non_update_fields = {"connection_id", "conn_id"}
setter_fields = {"password", "extra"}
@@ -71,7 +75,11 @@ def update_orm_from_pydantic(
orm_conn.set_extra(pydantic_conn.extra)
return
try:
- merged_extra = merge(json.loads(pydantic_conn.extra),
json.loads(orm_conn.extra))
+ old_extra = json.loads(orm_conn.extra)
+ merged_extra = merge(json.loads(pydantic_conn.extra), old_extra)
+ # A secret declared by one provider must not be handed to another
type's hook.
+ if orm_conn.conn_type == stored_conn_type:
+ merged_extra = restore_provider_sensitive_extra(merged_extra,
old_extra)
orm_conn.set_extra(json.dumps(merged_extra))
except json.JSONDecodeError:
# We can't merge fields in an unstructured `extra`
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_connections.py
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_connections.py
index 072f35167fd..c563bfeef40 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_connections.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_connections.py
@@ -16,6 +16,8 @@
# under the License.
from __future__ import annotations
+import json
+
import pytest
from pydantic import ValidationError
@@ -56,3 +58,61 @@ def test_redact_extra_rejects_non_json(extra):
"""
with pytest.raises(ValidationError):
ConnectionResponse.model_validate(_payload(extra=extra))
+
+
+class TestRedactProviderSensitiveExtra:
+ """
+ ``extra`` fields a provider declares sensitive are masked on every
connection.
+
+ Not only on that provider's type: after a ``conn_type`` change the stored
``extra`` still holds
+ the previous type's secrets.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _sensitive_fields(self, monkeypatch):
+ monkeypatch.setattr(
+
"airflow.api_fastapi.core_api.datamodels.connections._sensitive_extra_keys",
+ lambda: frozenset({"config", "extra__apprise__config"}),
+ )
+
+ def _extra(self, conn_type, extra):
+ response =
ConnectionResponse.model_validate(_payload(conn_type=conn_type,
extra=json.dumps(extra)))
+ return json.loads(response.extra)
+
+ def test_masks_declared_field_for_its_connection_type(self):
+ extra = self._extra("apprise", {"config": '{"path":
"mailto://user:pass@host"}', "tag": "alerts"})
+ assert extra == {"config": "***", "tag": "alerts"}
+
+ def test_masks_prefixed_form_of_declared_field(self):
+ extra = self._extra("apprise", {"extra__apprise__config":
"secret-config"})
+ assert extra == {"extra__apprise__config": "***"}
+
+ @pytest.mark.parametrize("key", ["config", "extra__apprise__config"])
+ def test_masks_declared_field_on_other_connection_types(self, key):
+ assert self._extra("http", {key: "secret-config"}) == {key: "***"}
+
+ def test_leaves_empty_declared_field_alone(self):
+ assert self._extra("apprise", {"config": ""}) == {"config": ""}
+
+
+def test_sensitive_extra_keys_reads_provider_declarations(monkeypatch):
+ from types import SimpleNamespace
+
+ from airflow.api_fastapi.core_api.datamodels import connections as module
+
+ widgets = {
+ "extra__apprise__config": SimpleNamespace(field_name="config",
is_sensitive=True),
+ "extra__apprise__tag": SimpleNamespace(field_name="tag",
is_sensitive=False),
+ "extra__my_type__api_secret": SimpleNamespace(field_name="api_secret",
is_sensitive=True),
+ }
+ monkeypatch.setattr(
+ "airflow.providers_manager.ProvidersManager",
+ lambda:
SimpleNamespace(_connection_form_widgets_from_metadata=widgets),
+ )
+ module._sensitive_extra_keys.cache_clear()
+ try:
+ assert module._sensitive_extra_keys() == frozenset(
+ {"config", "extra__apprise__config", "api_secret",
"extra__my_type__api_secret"}
+ )
+ finally:
+ module._sensitive_extra_keys.cache_clear()
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
index f8735b498e1..f53932d3a00 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
@@ -1007,6 +1007,93 @@ class TestPatchConnection(TestConnectionEndpoint):
assert response.json() == expected_response
_check_last_log(session, dag_id=None, event="patch_connection",
logical_date=None, check_masked=True)
+ @mock.patch(
+
"airflow.api_fastapi.core_api.datamodels.connections._sensitive_extra_keys",
+ autospec=True,
+ return_value=frozenset({"config", "extra__apprise__config"}),
+ )
+ @pytest.mark.parametrize("key", ["config", "extra__apprise__config"])
+ def test_patch_keeps_masked_provider_sensitive_extra(self, _, test_client,
session, key):
+ session.add(
+ Connection(
+ conn_id=TEST_CONN_ID,
+ conn_type="apprise",
+ extra=json.dumps({key: "secret-config", "tag": "alerts"}),
+ )
+ )
+ session.commit()
+ masked_extra =
test_client.get(f"/connections/{TEST_CONN_ID}").json()["extra"]
+ assert json.loads(masked_extra) == {key: "***", "tag": "alerts"}
+
+ response = test_client.patch(
+ f"/connections/{TEST_CONN_ID}",
+ json={
+ "connection_id": TEST_CONN_ID,
+ "conn_type": "apprise",
+ "extra": json.dumps({key: "***", "tag": "updated"}),
+ },
+ )
+
+ assert response.status_code == 200
+ session.expire_all()
+ db_conn =
session.scalar(select(Connection).filter_by(conn_id=TEST_CONN_ID))
+ assert json.loads(db_conn.extra) == {key: "secret-config", "tag":
"updated"}
+
+ @mock.patch(
+
"airflow.api_fastapi.core_api.datamodels.connections._sensitive_extra_keys",
+ autospec=True,
+ return_value=frozenset({"config", "extra__apprise__config"}),
+ )
+ def
test_patch_does_not_carry_provider_sensitive_extra_to_another_type(self, _,
test_client, session):
+ session.add(
+ Connection(conn_id=TEST_CONN_ID, conn_type="apprise",
extra='{"config": "secret-config"}')
+ )
+ session.commit()
+
+ response = test_client.patch(
+ f"/connections/{TEST_CONN_ID}",
+ json={"connection_id": TEST_CONN_ID, "conn_type": "http", "extra":
'{"config": "***"}'},
+ )
+
+ assert response.status_code == 200
+ session.expire_all()
+ db_conn =
session.scalar(select(Connection).filter_by(conn_id=TEST_CONN_ID))
+ assert json.loads(db_conn.extra) == {"config": "***"}
+
+ @mock.patch(
+
"airflow.api_fastapi.core_api.datamodels.connections._sensitive_extra_keys",
+ autospec=True,
+ return_value=frozenset({"config", "extra__apprise__config"}),
+ )
+ @pytest.mark.parametrize(
+ ("extra", "params"),
+ [
+ pytest.param({}, {}, id="extra-omitted"),
+ pytest.param(
+ {"extra": '{"config": "new"}'}, {"update_mask":
["conn_type"]}, id="extra-masked-out"
+ ),
+ ],
+ )
+ def test_patch_type_change_keeps_stored_provider_sensitive_extra_masked(
+ self, _, test_client, session, extra, params
+ ):
+ session.add(
+ Connection(conn_id=TEST_CONN_ID, conn_type="apprise",
extra='{"config": "secret-config"}')
+ )
+ session.commit()
+
+ response = test_client.patch(
+ f"/connections/{TEST_CONN_ID}",
+ json={"connection_id": TEST_CONN_ID, "conn_type": "http", **extra},
+ params=params,
+ )
+
+ assert response.status_code == 200
+ assert response.json()["conn_type"] == "http"
+ assert json.loads(response.json()["extra"]) == {"config": "***"}
+ get_response = test_client.get(f"/connections/{TEST_CONN_ID}")
+ assert json.loads(get_response.json()["extra"]) == {"config": "***"}
+
def test_patch_with_update_mask_validates_extra_as_json(self, test_client):
"""When update_mask includes 'extra', the extra field validator should
still reject invalid JSON."""
self.create_connection()
@@ -1325,6 +1412,52 @@ class TestConnection(TestConnectionEndpoint):
assert json.loads(db_conn.extra) == {"path": "/", "existing_key":
"existing_value"}
assert session.scalar(select(func.count()).select_from(Connection)) ==
initial_count
+ @mock.patch(
+
"airflow.api_fastapi.core_api.datamodels.connections._sensitive_extra_keys",
+ autospec=True,
+ return_value=frozenset({"path", "extra__fs__path"}),
+ )
+ @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+ def
test_should_reuse_masked_provider_sensitive_extra_of_existing_connection(
+ self, _, test_client, session
+ ):
+ session.add(Connection(conn_id=TEST_CONN_ID, conn_type="fs",
extra='{"path": "/secret"}'))
+ session.commit()
+ captured_extra = {}
+
+ def mock_test_connection(self):
+ captured_extra["value"] = self.extra
+ return True, "mocked"
+
+ body = {"connection_id": TEST_CONN_ID, "conn_type": "fs", "extra":
'{"path": "***"}'}
+ with mock.patch.object(Connection, "test_connection",
mock_test_connection):
+ response = test_client.post("/connections/test", json=body)
+
+ assert response.status_code == 200
+ assert json.loads(captured_extra["value"]) == {"path": "/secret"}
+
+ @mock.patch(
+
"airflow.api_fastapi.core_api.datamodels.connections._sensitive_extra_keys",
+ autospec=True,
+ return_value=frozenset({"path", "extra__fs__path"}),
+ )
+ @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+ def test_should_not_reuse_provider_sensitive_extra_for_another_type(self,
_, test_client, session):
+ session.add(Connection(conn_id=TEST_CONN_ID, conn_type="fs",
extra='{"path": "/secret"}'))
+ session.commit()
+ captured_extra = {}
+
+ def mock_test_connection(self):
+ captured_extra["value"] = self.extra
+ return True, "mocked"
+
+ body = {"connection_id": TEST_CONN_ID, "conn_type": "sqlite", "extra":
'{"path": "***"}'}
+ with mock.patch.object(Connection, "test_connection",
mock_test_connection):
+ response = test_client.post("/connections/test", json=body)
+
+ assert response.status_code == 200
+ assert json.loads(captured_extra["value"]) == {"path": "***"}
+
@mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
def test_should_merge_both_password_and_extra(self, test_client, session):
connection = Connection(