Require existing-connection read access when testing an existing connection - #67620
Conversation
…ection 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 create connections but not to read a given connection could effectively borrow that connection's secrets. 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
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
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 secrets backend, generate access-log entries in audited backends, and impose backend load by submitting arbitrary ids. 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
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 either silently fail to merge hidden fields for an existing team-owned connection, or — worse — merge a global secret with the same `conn_id` after the route had only authorized access to the team-owned scope. Both are cross-scope secret confusion paths. 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
…ons/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 None` and otherwise fall back to global lookups — so a team-scoped existing connection in a secrets backend would be authorized and looked up as global, defeating the multi-team isolation in deployments that don't keep connections in the DB. 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
9515795 to
ada3a4f
Compare
There was a problem hiding this comment.
Code and PR description are not in line.
If user isn't authorized to read a variable, existing variable is just ignored from the test.
method="GET",
details=ConnectionDetails(
conn_id=test_body.connection_id,
team_name=team_name,
),
user=user,
):
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.
Also we should probably not reference airflow-s issue from here. (more of the opposite way, reference this from airflow-s, linking is both ways anyway and will prevent people from searching for such airflow-s referencing issues)
True. I removed it from the SKILL already.
Hmm. mot sure. Indeed that's how it is implemented (assuming variable - connection :). The root of the problem here is however, that "require_access_connection" only uses connection parameter - but not body-specified connection_id. So no matter if the connection belongs to a team or not, with POST the user with test connection could just "run" and allow to enumerate the connections that otherwise they would not be able to know even exist. This is "defense-in-depth" - so nothing serious but still worth checking I guess. I think the description is pretty accurate. |
There was a problem hiding this comment.
Maybe I missed something but the part I believe isn't accurate is:
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.
I would expect a 403 response, while this isn't what the current implementation is doing. It's just ignoring/skipping the connection existing in the db.
if auth_manager.is_authorized_connection(
method="GET",
details=ConnectionDetails(
conn_id=test_body.connection_id,
team_name=team_name,
),
user=user,
)If this returns false, we proceed with existing_conn = None and the connection testing from the body. I would expect an explicit 403 saying, you don't have read access permission on this existing connection to be able to test it.
Ahh Right .. updated the description - I indeed changed the solution - to silently ignore it - based on security principle of not revealing anything - but forgot to update the description. If we had 403 - that on it's own would allow to enumerate the connections, that that caller should not even be aware of existence - so "silent" return (missing) is better than 403. Updated the description to explain it. |
pierrejeambrun
left a comment
There was a problem hiding this comment.
I figured it was some previous iteration remnants :)
LGTM, thanks!
…ection (apache#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 create connections but not to read a given connection could effectively borrow that connection's secrets. 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 secrets backend, generate access-log entries in audited backends, and impose backend load by submitting arbitrary ids. 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 either silently fail to merge hidden fields for an existing team-owned connection, or — worse — merge a global secret with the same `conn_id` after the route had only authorized access to the team-owned scope. Both are cross-scope secret confusion paths. 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 None` and otherwise fall back to global lookups — so a team-scoped existing connection in a secrets backend would be authorized and looked up as global, defeating the multi-team isolation in deployments that don't keep connections in the DB. 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
The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as apache#67620.
The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as apache#67620.
The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as apache#67620.
The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as apache#67620.
The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as apache#67620.
…#69957) The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as #67620.
…#69957) (#70010) The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as #67620. (cherry picked from commit a6d87ba)
…#69957) (#70010) The POST /connections/test endpoint restores an existing connection's stored credentials (password/extra) when the request sends the masked sentinel, so a saved connection can be tested without re-typing secrets. Connection configuration users have write-only access to those credentials and are not meant to view them; reusing them while the request points at a caller-supplied host or port applies them against a destination the caller chose rather than the connection's own, which is outside the intent of the retype-free test flow. Scope the credential reuse to requests that keep the stored connection's host and port. Follows the same hardening as #67620. (cherry picked from commit a6d87ba)
…ection (#67620) (#71428) * 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 create connections but not to read a given connection could effectively borrow that connection's secrets. 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 secrets backend, generate access-log entries in audited backends, and impose backend load by submitting arbitrary ids. 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 either silently fail to merge hidden fields for an existing team-owned connection, or — worse — merge a global secret with the same `conn_id` after the route had only authorized access to the team-owned scope. Both are cross-scope secret confusion paths. 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 None` and otherwise fall back to global lookups — so a team-scoped existing connection in a secrets backend would be authorized and looked up as global, defeating the multi-team isolation in deployments that don't keep connections in the DB. 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 (cherry picked from commit 779efc6) Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
…ection (#67620) (#71428) * 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 create connections but not to read a given connection could effectively borrow that connection's secrets. 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 secrets backend, generate access-log entries in audited backends, and impose backend load by submitting arbitrary ids. 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 either silently fail to merge hidden fields for an existing team-owned connection, or — worse — merge a global secret with the same `conn_id` after the route had only authorized access to the team-owned scope. Both are cross-scope secret confusion paths. 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 None` and otherwise fall back to global lookups — so a team-scoped existing connection in a secrets backend would be authorized and looked up as global, defeating the multi-team isolation in deployments that don't keep connections in the DB. 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 (cherry picked from commit 779efc6) Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
The
POST /api/v2/connections/testroute was authorizing the caller only as aconnection POST operation (i.e. "can create a connection"). When the request
body referenced an existing
connection_id, the route then loaded thatconnection from the configured secrets backend and merged its hidden fields
(
login,password, parts ofextra) into the test object. The route did notcheck whether the caller was authorized to read that existing connection — so
a caller authorized to create connections but not to read a given connection
could effectively borrow that connection's secrets, and could probe arbitrary
connection_ids to learn which ones exist.This change adds a team-scoped
GETauthorization check on the body'sconnection_idbefore any secrets backend is touched. If the caller is notauthorized to read that connection (or it does not exist), the existing
connection is ignored and the route falls through to the body-only test
path — exactly as it already does for a non-existent
connection_id. So:case (same status, same body shape) — the endpoint can't be used as an
existence oracle to enumerate protected connection ids.
connection_id, so no access-log entries, timing signals, or backend load aregenerated on the caller's behalf.
GETcheck and the secrets lookup both run in the connection's teamscope (
Connection.get_team_name, falling back to the body's validatedteam_namefor connections that live only in a team-aware backend like Vault /Kubernetes / Akeyless), preserving multi-team isolation.
There is intentionally no 403 for the read-access case: matching the
behaviour of the variable-test path, an existing connection the caller can't
read is silently dropped from the test rather than surfaced as a distinct
error. (The pre-existing route-level POST authorization still returns 403 for
callers who can't create connections at all.)
Test plan
test_unreadable_existing_connection_indistinguishable_from_missing—unauthorized read of an existing id returns the same status + body shape as a
non-existent id (no enumeration oracle).
test_unreadable_existing_connection_does_not_trigger_secrets_lookup—the secrets-backend lookup is gated on the
GETcheck, never run for anunauthorized id.
test_existing_connection_lookup_preserves_team_scope— the authorizedteam_nameis propagated toget_connection_from_secrets.test_secrets_only_team_connection_uses_body_team_scope— when theconnection has no metadata-DB row, the body's validated
team_namescopesboth the authz check and the lookup.
test_connections.py::TestConnectionstill pass.prek run --from-ref main --stage pre-commitclean on touched files.prek run --from-ref main --stage manualclean on touched files.Was generative AI tooling used to co-author this PR?
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