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 779efc6983b Require existing-connection read access when testing an 
existing connection (#67620)
779efc6983b is described below

commit 779efc6983b2b2497cd8f9c2d7bb376bae196825
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jun 25 14:35:57 2026 -0400

    Require existing-connection read access when testing an existing connection 
(#67620)
    
    * Require existing-connection read access when testing an existing 
connection
    
    The `POST /api/v2/connections/test` route was authorizing the caller only 
as a connection POST operation (i.e. "can create a connection"). When the 
request body referenced an existing `connection_id`, the route then loaded that 
connection from the configured secrets backend and merged its hidden fields 
(`login`, `password`, parts of `extra`) into the test object. The route did not 
check whether the caller was authorized to read that existing connection — so a 
caller authorized to crea [...]
    
    This change adds a `GET` authorization check on the existing connection 
before its secrets are merged into the test object. A caller authorized to 
create connections but not to read the existing `connection_id` now gets a 403.
    
    Reference: airflow-s/airflow-s#444
    
    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
    
    * Treat unreadable existing connection as missing in /connections/test
    
    Codex's adversarial review of the previous commit on this branch pointed 
out that returning 403 only when the existing connection is found AND 
unreadable creates an existence oracle: callers with route-level POST 
permission but no read permission could distinguish a protected connection_id 
(which returns 403) from a non-existent id (which falls through to the 
body-only test path).
    
    This commit removes the oracle by collapsing both cases — "connection not 
found" and "connection found but caller lacks read access" — into the same 
body-only test path. The hidden-field borrow is gated on `(existing_conn is not 
None and auth_manager.is_authorized_connection("GET", ...))` and the response 
shape is identical for any unreadable connection_id, regardless of whether it 
exists in the secrets backend.
    
    The regression test is updated to assert that the response to an unreadable 
existing connection_id is indistinguishable (status code + response body keys) 
from the response to a non-existent connection_id, under the same submitted 
body fields.
    
    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
    
    * Gate /connections/test secrets lookup on read authorization
    
    A second Codex adversarial-review pass on this branch pointed out that the 
previous follow-up commit still performed the secrets-backend lookup 
(`Connection.get_connection_from_secrets`) before the GET authorization check. 
Although the response shape was normalized so the caller could not distinguish 
unreadable-existing from missing, the lookup itself still ran for arbitrary 
connection ids — meaning an unauthorized caller could still: trigger backend 
queries against every configured s [...]
    
    This commit moves the GET authorization boundary *ahead of* the 
secrets-backend lookup. Team scope is resolved via `Connection.get_team_name`, 
which is a metadata-only DB lookup against the `connection` table and does not 
touch any secrets backend. Only when the caller is authorized to read the 
requested `connection_id` does the route call 
`Connection.get_connection_from_secrets`. Unauthorized callers fall through to 
the body-only test path without any secrets-backend interaction.
    
    A new regression test spies on `Connection.get_connection_from_secrets` 
with `wraps=` and asserts it is never called when the caller lacks GET access — 
locking in the gate at the test level.
    
    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
    
    * Preserve team_name through /connections/test secrets lookup
    
    A third Codex adversarial-review pass on this branch flagged that the route 
resolves and authorizes against `team_name` from `Connection.get_team_name`, 
but then calls `Connection.get_connection_from_secrets(connection_id)` without 
forwarding that team. `Connection.get_connection_from_secrets` accepts a 
`team_name` kwarg and forwards it to team-aware secrets backends (Vault, 
Akeyless, …); when omitted, those backends fall back to global secrets. In 
multi-team deployments this could ei [...]
    
    This commit threads `team_name` through to the secrets lookup, keeping the 
read scope consistent end-to-end. A new regression test enables `[core] 
multi_team=true`, creates a team-owned `TEST_CONN_ID`, and asserts the spy on 
`get_connection_from_secrets` is called with the correct `team_name` kwarg — 
locking in the team-scope propagation at the test level.
    
    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
    
    * Fall back to body team_name for secrets-only connections in 
/connections/test
    
    A fourth Codex adversarial-review pass on this branch flagged that for 
connections that live *only* in a team-aware secrets backend (Vault, 
Kubernetes, Akeyless, …) and have no metadata-DB row, 
`Connection.get_team_name(connection_id)` returns None. The route was then 
authorizing GET access and calling `get_connection_from_secrets(..., 
team_name=None)`, dropping the caller's already-validated body `team_name`. 
Team-aware backends only consult team-scoped paths when `team_name is not N 
[...]
    
    This commit falls back to `test_body.team_name` whenever the DB metadata 
lookup returns None. The body's `team_name` is already validated by 
`ConnectionBody.validate_team_name` (which rejects a non-None value unless 
`[core] multi_team` is enabled), so a non-None body value here is always 
already gated by that validator.
    
    A new regression test enables `[core] multi_team=true`, posts to 
`/connections/test` with a `team_name` body field, does *not* create a 
metadata-DB row, and asserts the secrets-backend spy is called with the body's 
`team_name`. This locks in the body-fallback path at the test level so future 
refactors can't silently drop team scope for secrets-only connections.
    
    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
---
 .../core_api/routes/public/connections.py          |  51 ++++++++-
 .../core_api/routes/public/test_connections.py     | 127 +++++++++++++++++++++
 2 files changed, 173 insertions(+), 5 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
index a1827253180..0d122946208 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
@@ -52,6 +52,7 @@ from airflow.api_fastapi.core_api.datamodels.connections 
import (
 )
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import (
+    AuthManagerDep,
     GetUserDep,
     ReadableConnectionsFilterDep,
     requires_access_connection,
@@ -292,7 +293,11 @@ def patch_connection(
 
 
 @connections_router.post("/test", 
dependencies=[Depends(requires_access_connection(method="POST"))])
-def test_connection(test_body: ConnectionBody) -> ConnectionTestResponse:
+def test_connection(
+    test_body: ConnectionBody,
+    user: GetUserDep,
+    auth_manager: AuthManagerDep,
+) -> ConnectionTestResponse:
     """
     Test an API connection.
 
@@ -305,13 +310,49 @@ def test_connection(test_body: ConnectionBody) -> 
ConnectionTestResponse:
     transient_conn_id = get_random_string()
     conn_env_var = f"{CONN_ENV_PREFIX}{transient_conn_id.upper()}"
     try:
-        # Try to get existing connection and merge with provided values
-        try:
-            existing_conn = 
Connection.get_connection_from_secrets(test_body.connection_id)
+        # Authorize read access on the requested ``connection_id`` *before*
+        # touching the secrets backends. The route-level POST dependency only
+        # verifies the caller can create connections; merging the existing
+        # connection's hidden fields also requires read access to that
+        # specific connection. Gating the backend lookup itself (rather than
+        # the post-load merge) prevents an unauthorized caller from using
+        # this endpoint to enumerate protected connection ids, generate
+        # access-log entries in audited backends, or impose backend load for
+        # arbitrary ids. ``get_team_name`` is a metadata-only DB lookup and
+        # does not touch the configured secrets backends.
+        #
+        # When the connection has no metadata-DB row (e.g. it lives only in
+        # a team-aware secrets backend like Vault or Kubernetes), fall back
+        # to the request body's validated ``team_name`` so the GET
+        # authorization and the secrets lookup both run in the right team
+        # scope. ``ConnectionBody.validate_team_name`` already rejects
+        # ``team_name`` from clients when ``[core] multi_team`` is off, so
+        # a non-None body value here is always already gated by that
+        # validator.
+        team_name = Connection.get_team_name(test_body.connection_id)
+        if team_name is None:
+            team_name = test_body.team_name
+        existing_conn: Connection | None = None
+        if auth_manager.is_authorized_connection(
+            method="GET",
+            details=ConnectionDetails(
+                conn_id=test_body.connection_id,
+                team_name=team_name,
+            ),
+            user=user,
+        ):
+            try:
+                existing_conn = Connection.get_connection_from_secrets(
+                    test_body.connection_id, team_name=team_name
+                )
+            except AirflowNotFoundException:
+                existing_conn = None
+
+        if existing_conn is not None:
             existing_conn.conn_id = transient_conn_id
             update_orm_from_pydantic(existing_conn, test_body)
             conn = existing_conn
-        except AirflowNotFoundException:
+        else:
             data = test_body.model_dump(by_alias=True)
             data["conn_id"] = transient_conn_id
             conn = Connection(**data)
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 74920b11d51..7c5ce03654e 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
@@ -1069,6 +1069,133 @@ class TestConnection(TestConnectionEndpoint):
         )
         assert response.status_code == 403
 
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    def 
test_unreadable_existing_connection_indistinguishable_from_missing(self, 
test_client):
+        """Route-level POST authorization is not enough on its own — when the
+        request references an existing connection_id, the caller must also be
+        authorized to read that specific connection before its hidden fields
+        are merged into the test object. A caller lacking read access must
+        get the same response shape as for a non-existent connection_id, so
+        the route cannot be used to enumerate protected connection ids."""
+        self.create_connection()
+
+        from airflow.api_fastapi.auth.managers.simple.simple_auth_manager 
import SimpleAuthManager
+
+        real_method = SimpleAuthManager.is_authorized_connection
+
+        def gated_authz(self, *, method, details=None, user=None):
+            if method == "GET":
+                return False
+            return real_method(self, method=method, details=details, user=user)
+
+        with mock.patch.object(SimpleAuthManager, "is_authorized_connection", 
gated_authz):
+            existing_response = test_client.post(
+                "/connections/test",
+                json={"connection_id": TEST_CONN_ID, "conn_type": "sqlite"},
+            )
+            missing_response = test_client.post(
+                "/connections/test",
+                json={"connection_id": "this_connection_does_not_exist", 
"conn_type": "sqlite"},
+            )
+
+        # Both calls reach the body-only test path (the unreadable existing
+        # connection is treated as if it did not exist), so status + body
+        # shape must be identical — no existence oracle for protected ids.
+        assert existing_response.status_code == missing_response.status_code
+        assert set(existing_response.json().keys()) == 
set(missing_response.json().keys())
+
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    def 
test_unreadable_existing_connection_does_not_trigger_secrets_lookup(self, 
test_client):
+        """The route must gate the secrets-backend lookup on the GET
+        authorization check, not perform the lookup and then suppress the
+        result. Otherwise an unauthorized caller can still force
+        ``Connection.get_connection_from_secrets`` to query every configured
+        secrets backend for arbitrary connection ids — leaking timing /
+        existence signals, generating access-log entries in audited
+        backends, and imposing backend load."""
+        self.create_connection()
+
+        from airflow.api_fastapi.auth.managers.simple.simple_auth_manager 
import SimpleAuthManager
+
+        real_method = SimpleAuthManager.is_authorized_connection
+
+        def gated_authz(self, *, method, details=None, user=None):
+            if method == "GET":
+                return False
+            return real_method(self, method=method, details=details, user=user)
+
+        with (
+            mock.patch.object(SimpleAuthManager, "is_authorized_connection", 
gated_authz),
+            mock.patch.object(
+                Connection,
+                "get_connection_from_secrets",
+                wraps=Connection.get_connection_from_secrets,
+            ) as spy_secrets,
+        ):
+            response = test_client.post(
+                "/connections/test",
+                json={"connection_id": TEST_CONN_ID, "conn_type": "sqlite"},
+            )
+
+        assert response.status_code == 200
+        spy_secrets.assert_not_called()
+
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    @conf_vars({("core", "multi_team"): "true"})
+    def test_existing_connection_lookup_preserves_team_scope(self, 
test_client, testing_team, session):
+        """The secrets-backend lookup must propagate the authorized
+        ``team_name`` to ``Connection.get_connection_from_secrets``.
+        Otherwise the call falls back to global / wrong-team paths in
+        team-aware backends (Vault, Akeyless, …) and can return a
+        cross-scope secret with the same ``conn_id`` — exactly what the
+        team-scoped authorization check above is supposed to prevent."""
+        self.create_connection(team_name=testing_team.name)
+        session.commit()
+
+        with mock.patch.object(
+            Connection,
+            "get_connection_from_secrets",
+            wraps=Connection.get_connection_from_secrets,
+        ) as spy_secrets:
+            response = test_client.post(
+                "/connections/test",
+                json={"connection_id": TEST_CONN_ID, "conn_type": "sqlite"},
+            )
+
+        assert response.status_code == 200
+        spy_secrets.assert_called_once_with(TEST_CONN_ID, 
team_name=testing_team.name)
+
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    @conf_vars({("core", "multi_team"): "true"})
+    def test_secrets_only_team_connection_uses_body_team_scope(self, 
test_client, testing_team):
+        """When the connection exists only in a team-aware secrets backend
+        (no metadata-DB row), ``Connection.get_team_name`` returns None.
+        The route must then fall back to the body's validated ``team_name``
+        so the GET authorization and the subsequent secrets lookup both
+        run in the right team scope — otherwise a team-scoped existing
+        connection would be authorized and looked up as global, losing
+        the multi-team isolation guarantee in deployments that keep
+        connections in Vault / Kubernetes / Akeyless rather than the DB."""
+        # No ``self.create_connection()`` — TEST_CONN_ID lives only in a
+        # secrets backend in this scenario.
+
+        with mock.patch.object(
+            Connection,
+            "get_connection_from_secrets",
+            wraps=Connection.get_connection_from_secrets,
+        ) as spy_secrets:
+            response = test_client.post(
+                "/connections/test",
+                json={
+                    "connection_id": TEST_CONN_ID,
+                    "conn_type": "sqlite",
+                    "team_name": testing_team.name,
+                },
+            )
+
+        assert response.status_code == 200
+        spy_secrets.assert_called_once_with(TEST_CONN_ID, 
team_name=testing_team.name)
+
     @skip_if_force_lowest_dependencies_marker
     @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
     @pytest.mark.parametrize(

Reply via email to