Repository navigation
fix(auth): an API key's authority is the key's, not its owner's (#16040) - #16049
Conversation
get_api_key_user validated a key and returned {"admin": user.is_platform_admin}
without ever reading api_key.scopes. A key created with narrow scopes carried
its owner's full authority, platform admin included, while the console that
issued it displayed the narrow scopes -- the scope a person selected and the
authority they granted were two different things, and nothing showed which was
real.
Severity correction: this has never been exploitable. get_api_key_user has no
callers -- the only references in the repository are its own definition and a
docstring -- so no route authenticates by API key through it. The issue was
filed as a live critical on the reading of the function body alone.
That is the reason to fix it now rather than a reason to defer. An unwired
dependency and a wired one are indistinguishable from the body, so the first
route to adopt this would have inherited a privilege escalation by adoption
rather than by decision, in a diff that looked like a one-line dependency
change.
admin is now an AND: a key cannot exceed its owner's authority, and cannot
exceed its own scopes either. Scope matching goes through APIKey.has_scope,
which already handles exact, wildcard and global-admin matching -- a second
implementation here would drift silently toward more privilege. scopes are
returned so a caller can enforce them without re-fetching the key.
The guard asserts source rather than behaviour because there is no route to
exercise; a test that invents its own caller proves the caller it invented.
Refs #16040
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
|
Review: the fix is right, and its correctness rests entirely on a function with zero tests in the repository. The change itself I agree with. The finding
That matters more than a normal coverage gap, because there is a concrete input class that inverts the fix. if scope in self.scopes: ...
if wildcard in self.scopes: ...
if "*" in self.scopes or "admin:*" in self.scopes: return TrueIf A key scoped I have not established that a string is reachable. The column is On the testsAll three assert on the AST of the source, not on behaviour. They verify the returned dict literal changed shape and that That is a reasonable choice for an unwired dependency — a behavioural test needs a session and an
assert APIKey(scopes=["read:*"]).has_scope("admin:*") is False
assert APIKey(scopes=["admin:*"]).has_scope("admin:*") is True
assert APIKey(scopes=["*"]).has_scope("admin:*") is True
assert APIKey(scopes="read:*").has_scope("admin:*") is False # the string caseFour lines, no database, and they pin the thing the AND actually depends on. The last one is the contrast that would have caught the substring class. Not blockingNone of this argues against merging. The change strictly improves on what is there, the exposure is zero while the dependency has no callers, and the fix is the right shape. I would rather see |
|
Follow-up: the reachability question I left open has been traced, and the answer refines my finding rather than settling it. A peer session traced the construction sites; I verified the trace independently and it holds, with one correction and one addition that move the conclusion. Verified:
The correction — the write path is better defended than "an annotation is not a check" suggests. That parameter is not the only barrier. The route feeds it from a Pydantic model:
That is a runtime validator, not an annotation. A request body sending The addition — that defence is on the wrong side of the function. Which is to say the string case is currently unreachable through the front door, and unguarded at the point that depends on it. My original framing had those the wrong way round: I treated the type annotation as weak evidence and did not notice the Pydantic model, and I treated the read side as safe because the column said This makes the one-line hardening more attractive than the tests, not less: scopes = self.scopes if isinstance(self.scopes, list) else []Failing closed is the right direction — a malformed Still not blocking, for the reasons in my first comment. Happy to push both the guard and the tests to this branch if you would rather not carry it. |
Review finding: get_api_key_user's new AND is exactly as strong as
APIKey.has_scope, and has_scope had no tests anywhere in the repository.
Every branch of has_scope is a membership test, and `in` against a string is a
substring test:
"*" in ["read:users"] -> False (list: membership)
"*" in "read:*" -> True (string: substring)
So a scalar scopes value made has_scope answer True to every scope asked,
collapsing the AND back to is_platform_admin alone -- the defect #16040 fixed,
restored silently and toward more privilege.
Not reachable through the API: APIKeyCreate.scopes is List[str] and validates at
the boundary before _build_api_key runs. But scopes is Mapped[list] over JSONB,
an annotation is not a database constraint, and JSONB stores a scalar happily --
so the list-ness is guaranteed by whoever writes the row, and a migration,
backfill or second writer is not defended. One line, failing closed.
24 tests: exact/wildcard/global-admin matching, the narrow-key-is-not-admin
pairing the fix rests on, eight malformed values, and a pin on the substring
behaviour itself so the guard cannot end up protecting against nothing.
has_scope is invoked unbound rather than through a constructed APIKey: the
mapper needs the whole ORM registry resolved, which would fail this suite for a
reason unrelated to scopes.
Refs #16040
|
Both review findings addressed at The
|
Security review — the fix is correct; the guard that pins it cannot see the mutation that undoes itVerdict: the authority change is sound and I'd ship it. The AST guard asserting it is satisfiable by two mutations, one of which restores the vulnerability in a stronger form than the original.
The fix itself — verified, not taken
I traced all three arms of
So no narrow admin scope escalates. Had the matching gone the other way — required pattern matched against held scopes — The fail-closed guard is a real find, not defensive padding. And On the "zero tests" concern: this PR closes it. The findingThe other file asserts only that the rendered rendered = ast.unparse(admin_expr)
assert "api_key" in rendered
assert "has_scope" in renderedTwo mutations satisfy both: # 1. operator swap — restores the defect, and widens it
"admin": user.is_platform_admin or api_key.has_scope("admin:*")
# 2. wrong scope literal
"admin": user.is_platform_admin and api_key.has_scope("chat:use")The first is the one that matters. An The AST approach is defensible here — the dependency has no callers, so there is no route to exercise, and building one to test an unwired dependency would be a strange thing to merge. But the gap is not "AST instead of behaviour", it is asserting on the rendered string instead of the tree. The tree carries the operator: assert isinstance(admin_expr, ast.BoolOp) and isinstance(admin_expr.op, ast.And), (
f"`admin` is derived as {rendered} — an OR grants admin when EITHER side holds, "
"which is #16040 with a wider blast radius than the original."
)One assertion, same file, no DB, and it kills the mutation the current pair cannot. Two things I checked and would not changeFixing an unexploitable defect is right, and the stated reason is the best argument for it: "an unwired dependency and a wired one are indistinguishable from the function body, and the first route to adopt this would have inherited the defect silently rather than by anyone deciding to." The no-callers claim is what makes this low-urgency, not what makes it low-value. Returning CII could not reproduce the red shard 8/12: the PR currently reads 2 passed, 30 pending, 1 skipping, 0 failing. That is a re-run in progress, not a pass — 0 failing among 30 pending is not evidence, and I am not reading it as one. Worth re-checking against a completed run before merging; if shard 8 reds again, the log is retrievable with |
What Changed
get_api_key_user(autobot-slm-backend/services/auth.py:341) validated an API key and then returned the user's authority, never the key's:Adds
repo_tests/api_key_scopes_are_enforced_16040_test.py(3 passed).Thinking Path
A key created with narrow scopes would have carried its owner's full authority — platform admin included — while the console that issued it displayed the narrow scopes. The scope a person selected and the authority they granted were two different things, and nothing in the UI showed which one was real.
Severity correction, stated up front: this has never been exploitable.
get_api_key_userhas no callers. Grepping the repository returns exactly two hits — its own definition at:341and a docstring mentioning it at:395. No route authenticates by API key through this dependency.That is the argument for fixing it now, not for downgrading it. An unwired dependency and a wired one are indistinguishable from the function body, so the first route to adopt this would have inherited a privilege escalation by adoption rather than by anyone deciding to — in a diff that looked like adding one
Depends(...). Fixing it now costs a commit; fixing it after adoption is an incident. It should also not sit on the board as an active breach, or someone triages it as one and rotates keys that were never over-privileged.The fix uses a primitive that already existed and was never called.
APIKey.has_scope(autobot_shared/user_management/models/api_key.py:192) already implements exact match, wildcard (chat:*matcheschat:use) and global admin (*,admin:*). Writing a second scope check inauth.pywould have been the more obvious change and the wrong one — two implementations of one rule drift, and here the drift direction is toward more privilege.adminis an AND: a key can exceed neither its owner's authority nor its own scopes.scopesis returned so a consumer can enforce without re-fetching the key — a route that must go back to the database to make a scope decision is a route that will skip it.Verification
repo_tests/api_key_scopes_are_enforced_16040_test.py— 3 passed.adminexpression unparses touser.is_platform_admin, which contains noapi_key, so the first assertion trips.adminconsults the key; the payload exposesscopes; andhas_scopestays the single implementation — a literal"admin:*" inor"*" in api_key.scopesappearing inauth.pyfails the suite.ast.parseclean on the modified file.Model Used
Claude Opus 5
Single-issue rationale
Single-issue rationale: an authorisation fix must be readable in one pass by someone checking exactly one question — whose authority does a key carry — and the adjacent findings (#16041–#16043) are host-lifecycle features, not auth.
#16040 is a single-function authorisation fix with no same-scope sibling open. The adjacent findings from the same research pass (#16041–#16043) are host-lifecycle features rather than auth, and bundling an authorisation change with anything else would make this diff harder to clear than it deserves to be — it should be readable in one pass by someone checking exactly one question: whose authority does a key carry.
Credit: found by
autobot-ai-0b. The unwired-caller correction is the same class as the finding itself — a function body cannot tell you whether anyone calls it.Refs #16040