Skip to content

Require existing-connection read access when testing an existing connection - #67620

Merged
potiuk merged 5 commits into
apache:mainfrom
potiuk:tighten-connections-test-existing-connection-authz
Jun 25, 2026
Merged

Require existing-connection read access when testing an existing connection#67620
potiuk merged 5 commits into
apache:mainfrom
potiuk:tighten-connections-test-existing-connection-authz

Conversation

@potiuk

@potiuk potiuk commented May 27, 2026

Copy link
Copy Markdown
Member

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, and could probe arbitrary
connection_ids to learn which ones exist.

This change adds a team-scoped GET authorization check on the body's
connection_id before any secrets backend is touched. If the caller is not
authorized 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:

  • An unauthorized caller's response is indistinguishable from the not-found
    case (same status, same body shape) — the endpoint can't be used as an
    existence oracle to enumerate protected connection ids.
  • The secrets backends are not queried at all for an unauthorized
    connection_id, so no access-log entries, timing signals, or backend load are
    generated on the caller's behalf.
  • The GET check and the secrets lookup both run in the connection's team
    scope
    (Connection.get_team_name, falling back to the body's validated
    team_name for 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 GET check, never run for an
    unauthorized id.
  • test_existing_connection_lookup_preserves_team_scope — the authorized
    team_name is propagated to get_connection_from_secrets.
  • test_secrets_only_team_connection_uses_body_team_scope — when the
    connection has no metadata-DB row, the body's validated team_name scopes
    both the authz check and the lookup.
  • Existing tests in test_connections.py::TestConnection still pass.
  • prek run --from-ref main --stage pre-commit clean on touched files.
  • prek run --from-ref main --stage manual clean on touched files.
Was generative AI tooling used to co-author this PR?
  • Yes — Claude Opus 4.7 (1M context)

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

potiuk added 5 commits June 4, 2026 00:47
…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
@potiuk
potiuk force-pushed the tighten-connections-test-existing-connection-authz branch from 9515795 to ada3a4f Compare June 3, 2026 22:49

@pierrejeambrun pierrejeambrun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@potiuk potiuk added this to the Airflow 3.3.0 milestone Jun 21, 2026
@potiuk

potiuk commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

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.

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.

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.

@pierrejeambrun pierrejeambrun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@potiuk

potiuk commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

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 pierrejeambrun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured it was some previous iteration remnants :)

LGTM, thanks!

@potiuk
potiuk merged commit 779efc6 into apache:main Jun 25, 2026
145 checks passed
@potiuk
potiuk deleted the tighten-connections-test-existing-connection-authz branch June 25, 2026 18:35
karenbraganz pushed a commit to karenbraganz/airflow that referenced this pull request Jun 30, 2026
…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
pierrejeambrun added a commit to astronomer/airflow that referenced this pull request Jul 16, 2026
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.
pierrejeambrun added a commit to astronomer/airflow that referenced this pull request Jul 16, 2026
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.
pierrejeambrun added a commit to astronomer/airflow that referenced this pull request Jul 16, 2026
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.
pierrejeambrun added a commit to astronomer/airflow that referenced this pull request Jul 16, 2026
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.
pierrejeambrun added a commit to astronomer/airflow that referenced this pull request Jul 16, 2026
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.
pierrejeambrun added a commit that referenced this pull request Jul 16, 2026
…#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.
pierrejeambrun added a commit that referenced this pull request Jul 17, 2026
…#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)
vatsrahul1001 pushed a commit that referenced this pull request Aug 5, 2026
…#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)
pierrejeambrun added a commit that referenced this pull request Aug 11, 2026
…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>
vatsrahul1001 pushed a commit that referenced this pull request Aug 13, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:API Airflow's REST/HTTP API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants