Skip to content

Refuse the team agnostic fall-through for a team scoped AWS secret name - #70878

Merged
potiuk merged 3 commits into
apache:mainfrom
potiuk:scope-amazon-secrets-lookups-to-their-team
Aug 1, 2026
Merged

Refuse the team agnostic fall-through for a team scoped AWS secret name#70878
potiuk merged 3 commits into
apache:mainfrom
potiuk:scope-amazon-secrets-lookups-to-their-team

Conversation

@potiuk

@potiuk potiuk commented Jul 31, 2026

Copy link
Copy Markdown
Member

The team scoped lookup runs first and, on a miss, falls through to the team
agnostic name. A guard was meant to stop a caller naming another team's secret:

return team_name is None and bool(re.fullmatch(..., secret_id))

Its first condition is team_name is None, so it does nothing whenever a team
scope is supplied — exactly when it matters. A caller authorised for one team
could supply an id spelling out another team's namespace; the team scoped probe
missed, and the team agnostic lookup resolved the other team's secret.

Approach

  1. the team scoped lookup runs first — safe by construction, since it can only
    ever build the caller's own namespace;
  2. after it misses, an id that spells out any team namespace is refused rather
    than resolved through the team agnostic name;
  3. otherwise the team agnostic lookup proceeds as before.

The id is never parsed to work out which team it names, because it cannot be:
a team name may itself contain the separator, so nothing in the string
distinguishes one team's namespace from another whose name extends it. Comparing
the id against the prefix the caller's own team builds looks equivalent and is
not — a caller in team a matches a--b's namespace on the prefix and would read
its secrets. Only the caller's own namespace is ever constructed, never parsed.

Behaviour change

An id that spells out a team scoped name is no longer resolvable through the team
agnostic name from any scope, including the team that owns it — that spelling is
what made a prefix match look like ownership. Callers reach their own team's
secrets with the bare id plus their team scope, which is unaffected, as are
single-team and non-team deployments.

Test plan

  • New tests: a caller scoped to one team cannot reach another team's secret by
    naming it, and a team whose name extends the caller's (teama--prod vs
    teama) is not reachable either
  • Both new tests fail against the unmodified backend — the target secret is
    resolvable in the fixture, so a backend that falls through returns its value
  • Existing team-scope tests unchanged and passing
  • ruff check / ruff format clean
Was generative AI tooling used to co-author this PR?
  • Yes — Claude Opus 5 (1M context)

Generated-by: Claude Opus 5 (1M context) following the guidelines at
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions

The team scoped lookup is tried first and then, on a miss, the lookup falls
through to the team agnostic name. A guard was meant to stop a caller naming
another team's secret, but its first condition is `team_name is None`, so it
does nothing whenever a team scope is supplied -- exactly when it matters. A
caller authorised for one team could therefore supply an id that spells out
another team's namespace and resolve that team's secret.

Refuse the fall-through instead. The team scoped lookup still runs first and is
safe by construction, since it can only ever build the caller's own namespace.
After it misses, an id that spells out any team namespace is refused rather than
resolved through the team agnostic name.

The id is never parsed to work out which team it names, because it cannot be: a
team name may itself contain the separator, so nothing distinguishes one team's
namespace from another whose name extends it. Comparing the id against the
prefix the caller's own team builds looks equivalent and is not -- a caller in
team `a` matches `a--b`'s namespace on the prefix and would read its secrets.

Generated-by: Claude Opus 5 (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 requested a review from o-nikolas as a code owner July 31, 2026 22:49
Refusing only the team agnostic fall-through left the team scoped lookup
open to the same ambiguity it was meant to close: a caller in team 'a'
asking for 'b--c' builds exactly the name team 'a--b' builds for 'c', and
that lookup runs first, so the fall-through is never reached.

Deciding this at the entry points instead means the ambiguity is settled
before a name is built, in Secrets Manager and Parameter Store alike.
@eladkal
eladkal requested a review from vincbeck August 1, 2026 01:31

@shahar1 shahar1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approach looks right and the tests are what convinced me; a few observations inline and below, none blocking — though two of them look in-scope for this PR rather than follow-ups.

The old guard's first condition was team_name is None, so it did nothing exactly when a team scope was supplied. Moving the refusal ahead of both lookups rather than only the team agnostic fall-through is the correct shape, and test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace is the test that earns it: team my_team asking for prod--test_postgres builds exactly the name team my_team--prod builds for test_postgres, so the team scoped probe hits another team's secret before any fall-through is reached. Guarding only the fall-through would have left that open.

1. The same flaw is in core's EnvironmentVariablesBackend, and isn't fixed here

The guard quoted in the PR description —

return team_name is None and bool(re.fullmatch(..., secret_id))

— is airflow-core/src/airflow/secrets/environment_variables.py:66, not the Amazon code this PR patches (the Amazon one has no bool(...) wrapper). That backend has the identical fall-through:

  • team scoped name: AIRFLOW_CONN_ + _<TEAM>___ + <CONN_ID>
  • team agnostic name: AIRFLOW_CONN_<CONN_ID>

A caller scoped to teama requesting conn_id _teamb___myconn misses AIRFLOW_CONN__TEAMA____TEAMB___MYCONN, then falls through to AIRFLOW_CONN__TEAMB___MYCONN — team b's connection. The collision variant applies too: teama asking for prod___myconn builds the same variable name that team teama___prod builds for myconn.

Fixing the two Amazon backends while the code the description quotes stays broken leaves the sibling caller open. Fine as a separate PR, but it seems worth doing together, or at least tracking.

2. The provider docs still describe the old, conditional refusal

  • providers/amazon/docs/secrets-backends/aws-secrets-manager.rst:176
  • providers/amazon/docs/secrets-backends/aws-ssm-parameter-store.rst:152

Both still carry:

Connection ids and variable keys matching <team>--<name> are reserved for team-scoped lookups. A request without team context for a key matching this pattern will return None to prevent cross-team access.

After this PR the refusal is unconditional in every scope, including the owning team — which is exactly the behaviour change the description calls out. Worth updating in the same PR, ideally with a line telling a deployment that already has ids containing -- what to do about them.

3. The description's rationale contradicts its own tests

Approach bullet 1 says the team scoped lookup is "safe by construction, since it can only ever build the caller's own namespace", while the docstring of test_team_scoped_lookup_cannot_reach_a_longer_teams_namespace says "The team scoped name is not safe by construction -- the id can extend it." The code follows the test — the refusal correctly runs before both lookups — so it is the rationale that is stale, and it will land in the commit message as written.

Nit — the helper is duplicated verbatim

TEAM_SEP, the 15-line _names_a_team_namespace docstring and _log_refusal are now copy-pasted identically into both modules. The two backends already duplicate the _get_secret shape so this follows the local grain, but a shared helper would keep the rationale in one place — it is the kind of comment that drifts once only one copy gets edited.


Not requesting anyone, just flagging who has context on this code: justinpakzad authored the original guard (#65703) and its logging follow-up (#66059), and vincbeck has been across the Amazon secrets backends and multi-team.


This review was drafted by an AI-assisted tool and
confirmed by an Airflow maintainer. The findings
below are observations, not blockers; an Airflow
maintainer — a real person — will take the next look at the
PR. If you think a finding is mis-applied, please reply on
the PR and a maintainer will weigh in.

More on how Airflow handles maintainer review:
contributing-docs/05_pull_requests.rst.

Comment thread providers/amazon/src/airflow/providers/amazon/aws/secrets/secrets_manager.py Outdated
Comment thread providers/amazon/src/airflow/providers/amazon/aws/secrets/systems_manager.py Outdated
Comment thread providers/amazon/tests/unit/amazon/aws/secrets/test_secrets_manager.py Outdated
The check used to live in _get_secret, which all three getters share. Moving it
up into get_conn_value and get_variable left get_config reaching _get_secret
unguarded, so the helper's claim that such an id is refused for every lookup was
no longer true. Not a cross-team read -- get_config never receives a team_name --
but the two getters and the docstring disagreed with the third.

Guard get_config too, and correct the docstring to name the three getters.

_names_a_team_namespace uses no instance state, so it becomes a staticmethod.

The refusal-logging test asserted on the rendered sentence. Airflow logs through
structlog, which renders format args into msg before the stdlib record exists, so
record.args is empty and there is no structured payload to assert on; assert on
level, logger and the refused id instead, which is what the test is actually
about, and extend it to cover get_config.
@potiuk
potiuk merged commit 0191b11 into apache:main Aug 1, 2026
35 checks passed
@potiuk
potiuk deleted the scope-amazon-secrets-lookups-to-their-team branch August 1, 2026 10:26
@potiuk

potiuk commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks @shahar1 — all four addressed, and the get_config one was a real regression I introduced. Moving the check out of _get_secret and up into the two team-aware getters left the third reaching _get_secret bare, so the helper's "refused for every lookup" claim stopped being true the moment I wrote it.

  • get_config guarded in both secrets_manager.py and systems_manager.py, and the docstring now names the three getters instead of claiming universality. Confirmed the test fails without the guard (assert 2 == 3), so it pins the behaviour rather than just passing.
  • @staticmethod on _names_a_team_namespace in both files.
  • The log assertion — I tried your structured suggestion first and it does not work here: Airflow logs through structlog, which renders the format args into msg before the stdlib record exists, so record.args is () and there is no structured payload to assert on. Settled on level + logger + the refused id, which is the load-bearing data, and left a comment saying why so the next person does not repeat the attempt. Extended to cover get_config.

On the duplicated docstring and _log_refusal between the two files — agreed it is not ideal, but the two backends share no base class today and I would rather not introduce one inside a security fix. Left as is.

One thing you should know: the same get_config gap is in #70876 (Azure), which is already merged. Same shape, and you approved that one before finding this in the sibling. Not a cross-team read there either — get_config never receives a team_name — but the merged docstring makes the same false claim. Opening a follow-up.

potiuk added a commit to potiuk/airflow that referenced this pull request Aug 1, 2026
Follow-up to apache#70876. The check used to live in _get_secret, which all three
getters share. Moving it up into get_conn_value and get_variable left get_config
reaching _get_secret unguarded, so the helper's claim that such an id is refused
for every lookup stopped being true. Not a cross-team read -- get_config never
receives a team_name -- but two getters and the docstring disagreed with the
third.

The same gap was found in review of the Amazon sibling (apache#70878) and fixed there;
this is the Azure half, which had already merged by then.

_names_a_team_namespace stays an instance method here, unlike Amazon's: it builds
the candidate path with self.build_path and self.sep.

The refusal-logging assertion uses getMessage() rather than msg, since how a
record carries its payload differs between the Airflow version this runs on and
the ones the provider compat tests use.
potiuk added a commit that referenced this pull request Aug 1, 2026
The refusal-logging assertions in the Amazon secrets tests read record.msg
directly. How a record carries its payload depends on the Airflow version: on
main, structlog renders the format args into msg before the stdlib record exists,
so args is empty and msg holds the final text; on the versions the provider
compat tests run against, plain stdlib logging leaves msg as the format string
with the values in args.

Reading msg therefore passes on main and fails under Compat 2.11.1 and 3.0.6,
which is where these currently break:

  assert 0 == 1
  where 0 = sum(refused_id in r.msg for r in refusals)

getMessage() renders in both shapes. The assertion still targets the refused id
rather than the wording, so rephrasing the warning stays free.

Introduced in #70878, where the assertions were moved off getMessage and verified
only against main.
potiuk added a commit to potiuk/airflow that referenced this pull request Aug 1, 2026
Follow-up to apache#70876. The check used to live in _get_secret, which all three
getters share. Moving it up into get_conn_value and get_variable left get_config
reaching _get_secret unguarded, so the helper's claim that such an id is refused
for every lookup stopped being true. Not a cross-team read -- get_config never
receives a team_name -- but two getters and the docstring disagreed with the
third.

The same gap was found in review of the Amazon sibling (apache#70878) and fixed there;
this is the Azure half, which had already merged by then.

_names_a_team_namespace stays an instance method here, unlike Amazon's: it builds
the candidate path with self.build_path and self.sep.

The refusal-logging assertion uses getMessage() rather than msg, since how a
record carries its payload differs between the Airflow version this runs on and
the ones the provider compat tests use.
Miretpl pushed a commit that referenced this pull request Aug 1, 2026
…#70899)

Follow-up to #70876. The check used to live in _get_secret, which all three
getters share. Moving it up into get_conn_value and get_variable left get_config
reaching _get_secret unguarded, so the helper's claim that such an id is refused
for every lookup stopped being true. Not a cross-team read -- get_config never
receives a team_name -- but two getters and the docstring disagreed with the
third.

The same gap was found in review of the Amazon sibling (#70878) and fixed there;
this is the Azure half, which had already merged by then.

_names_a_team_namespace stays an instance method here, unlike Amazon's: it builds
the candidate path with self.build_path and self.sep.

The refusal-logging assertion uses getMessage() rather than msg, since how a
record carries its payload differs between the Airflow version this runs on and
the ones the provider compat tests use.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants