feat(AGX1-263): agent_api_keys route migration to FGAC (404 collapse + two-factor)#252
Draft
dm36 wants to merge 1 commit into
Draft
Conversation
7 tasks
b94f0cb to
d15bc88
Compare
9668f1a to
e72df68
Compare
d15bc88 to
0cbba3d
Compare
…se and two-factor mutations Mirrors AGX1-275 (PR #249) for agent_api_keys. Wires Spark AuthZ checks into every api_key route, collapses denials to 404 (so name/id probes can't distinguish "present in another tenant" from "absent"), and relies on SpiceDB's transitive expansion of api_key.{update,delete} (= editor & parent_agent->update & tenant_gate) for two-factor mutations rather than issuing two explicit checks at the route layer. - src/utils/agent_api_key_authorization.py (new): _check_api_key_or_collapse_to_404 — catches AuthorizationError, raises ItemDoesNotExist. Same shape as Asher's task helper. - src/utils/authorization_shortcuts.py: DAuthorizedId routes AgentexResourceType.api_key through the wrap. (DAuthorizedName isn't used for api_keys; the name lookup is (agent_id, name, api_key_type), not a single globally-unique path param — the route handlers call the collapse helper inline instead.) - src/api/routes/agent_api_keys.py: * POST: explicit agent.update on parent (no api_key resource yet). * GET list: DAuthorizedResourceIds + filter; None passes through. * GET /name/{name}: inline collapse helper. * GET /{id}: DAuthorizedId(api_key, read). * DELETE /{id}: DAuthorizedId(api_key, delete). Two-factor via SpiceDB schema (api_key.delete expands to parent_agent.update); no second route-layer check. * DELETE /name/{api_key_name}: inline collapse helper. - tests/unit/api/test_agent_api_keys_authz.py (new): 12 tests, all pass. Stacked on dhruv/agx1-272-agent-api-keys-dual-write (PR A). Does NOT touch dual-write logic. Does NOT modify agentex-auth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
0cbba3d to
4335ca7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related work
PR B of the agent_api_keys FGAC stack. Stacks on #248 (dual-write PR A). Mirrors the AGX1-275 task pattern set by Asher in #249 (route-layer rewire + 404 collapse) — same helper shape, same dep-factory hook, same test layout.
Cross-repo stack
parent_resourcekwarg onregister_resourceapi_keymappingParent ticket: AGX1-263.
Summary
agent_api_keysroutes.404(ItemDoesNotExist) on the id and name surfaces so callers can't distinguish "present in another tenant" from "absent".GET /agent_api_keysto the set of api_keys the caller can read.agent.updateexplicitly onPOST /agent_api_keys(no api_key resource exists yet — only enforcement surface at create time).delete: relies on SpiceDB's transitive expansion (api_key.delete = editor & parent_agent->update & tenant_gate), not a second route-layercheck— same approach as feat(AGX1-275): per-RPC task permission rewire and 404/403 wrap #249. Theparent_agentrelation is now populated by feat(AGX1-272): dual-write agent_api_keys to spark-authz behind FGAC flag #248'sregister_resource(parent=agent)so the cascade works end-to-end.What changed
src/utils/agent_api_key_authorization.py(new):_check_api_key_or_collapse_to_404(authorization, api_key_id, operation)— catchesAuthorizationError, raisesItemDoesNotExist. Direct parallel of_check_task_or_collapse_to_404.src/utils/authorization_shortcuts.py:DAuthorizedIdnow routesAgentexResourceType.api_keythrough the collapse wrap (gated branch alongside the existing task-child and direct-resource branches).src/api/routes/agent_api_keys.py: per-route wiring. The name routes call the collapse helper inline because the lookup key is(agent_id, name, api_key_type)— not a single globally-unique name path param — soDAuthorizedNamedoesn't fit.Why structural choices
Why no
DAuthorizedNameextension. Asher extendedDAuthorizedNamefor tasks becausetasks.nameis a single globally-unique path component.agent_api_keysnames are scoped — the unique key is(agent_id, name, api_key_type)and the path is/agent_api_keys/name/{name}?agent_id=.... Stuffing this intoDAuthorizedName'sname -> repository.get(name=...)shape would have required changing its signature in a way that doesn't generalize. Inline collapse helper at the route handler is cleaner and matches the cross-resource composite lookup.Why no explicit two-factor at delete. Per the SpiceDB schema,
api_key.delete = internal_effective_editor & parent_agent->update & internal_tenant_gate. Issuing a secondauthorization_service.check(agent.update, agent_id)at the route layer would only duplicate what spark-authz already enforces atomically. Mirrors #249's approach. Theparent_agentrelation is populated by #248's use case callingregister_resource(parent=AgentexResource.agent(agent_id))— that's what closes the cascade end-to-end.Why explicit parent check at create. Create is the one place where no api_key resource exists yet, so SpiceDB cannot gate transitively. The route MUST issue
check(agent.update, parent_agent_id)directly. This is the only explicit two-factor check in the file.Tests
tests/unit/api/test_agent_api_keys_authz.py— 12/12 pass.TestCheckApiKeyOrCollapseTo404(allow + denied-collapse + delete-op forwarding).TestDAuthorizedIdApiKeyWrap(denial → 404, allow returns id, delete op propagation).TestNameRouteCollapse(both name handlers; verifies delete is NOT invoked when check fails).TestListFiltering(authorized-subset filter + None-passthrough).TestCreateParentAgentCheck(explicit parent-agent.update + create-not-invoked on denial).Test plan
uv run pytest agentex/tests/unit/api/test_agent_api_keys_authz.py— 12 passed.Out of scope
spark_mapping.pyupdates — handled in #354 (merged).Greptile Summary
This PR wires Spark AuthZ (SpiceDB) checks into all six
agent_api_keysroutes, mirroring the task FGAC pattern from PR #249. Denials on id- and name-based lookups are collapsed to 404 to prevent cross-tenant existence probing, and list results are filtered to the set the caller can read.agent_api_key_authorization.py(new):_check_api_key_or_collapse_to_404wraps SpiceDB checks and raisesItemDoesNotExiston any denial, used by both the inline name-route handlers and theDAuthorizedIdbranch inauthorization_shortcuts.py.agent_api_keys.py: Parentagent.updatecheck at create time (no resource row yet);DAuthorizedId(api_key, delete)on id-based delete; inline collapse helper on both name routes;DAuthorizedResourceIdsenumeration withNone-passthrough on list.test_agent_api_keys_authz.py(new): 12 unit tests covering the helper,DAuthorizedIdwrap, name-route collapse, list filtering, and create-time parent check.Confidence Score: 3/5
The route-layer wiring is structurally sound, but the name routes introduce a new distinguishable 404 response body that reveals api_key existence to any caller who can resolve an agent_id — the opposite of the privacy invariant the PR is trying to establish.
The id-route collapse and list filtering work correctly. The create-time parent check is the right approach. However, both name-route handlers now expose two different 404 body formats depending on whether the key exists in the database, meaning cross-tenant existence probing via the name surface is still possible after this PR. This is a new gap introduced here, not pre-existing, and it contradicts the core security property the PR is designed to enforce.
agentex/src/api/routes/agent_api_keys.py — specifically the pre-auth 404 raises in
get_agent_api_key_by_nameanddelete_agent_api_key_by_nameSecurity Review
agent_api_keys.pyname routes: The "key absent in DB" path raisesHTTPExceptionwith\"Agent api_key '{name}' not found for agent ID {agent.id}\", while the "key present but denied" path raisesItemDoesNotExistwith\"Item with id '{id}' does not exist.\". Both return HTTP 404, but the differing response bodies allow a caller who can resolve anagent_idto probe for api_key name existence, defeating the stated existence-leak collapse onget_agent_api_key_by_nameanddelete_agent_api_key_by_name.Important Files Changed
authorizationparameter lacks a type annotation.Sequence Diagram
sequenceDiagram participant C as Caller participant R as Route Handler participant AU as agent_use_case participant AK as agent_api_key_use_case participant SDB as SpiceDB (authorization_service) Note over R: POST /agent_api_keys (create) C->>R: POST with agent_id/agent_name R->>AU: get(id/name) AU-->>R: agent entity R->>SDB: check(agent.update, agent_id) alt denied SDB-->>R: AuthorizationError R-->>C: 403 else allowed SDB-->>R: OK R->>AK: create(...) AK-->>R: api_key entity R-->>C: 200 CreateAPIKeyResponse end Note over R: GET /agent_api_keys/name/{name} C->>R: GET with name + agent_id R->>AU: get(id/name) AU-->>R: agent entity R->>AK: get_by_agent_id_and_name(...) alt not found in DB AK-->>R: None R-->>C: 404 message-A else found AK-->>R: entity R->>SDB: check(api_key.read, entity.id) alt denied SDB-->>R: AuthorizationError to ItemDoesNotExist R-->>C: 404 message-B (different body) else allowed R-->>C: 200 AgentAPIKey end end Note over R: DELETE /agent_api_keys/{id} C->>R: DELETE with id R->>SDB: check(api_key.delete, id) via DAuthorizedId Note over SDB: SpiceDB transitively checks parent_agent.update alt denied SDB-->>R: AuthorizationError to ItemDoesNotExist R-->>C: 404 else allowed R->>AK: delete(id) R-->>C: 200 deleted endComments Outside Diff (2)
agentex/src/api/routes/agent_api_keys.py, line 163-179 (link)The name routes produce two structurally different 404 responses depending on whether the key exists in the database. If the key is absent,
HTTPExceptionis raised with"Agent api_key '{name}' not found for agent ID {agent.id}". If the key exists but the caller is denied,ItemDoesNotExistis raised with"Item with id '{api_key_id}' does not exist.". Both return HTTP 404, but a cross-tenant caller who can resolve anagent_idcan probe for key-name existence by comparing the response body — exactly the information leak the collapse is meant to prevent. The same issue exists indelete_agent_api_key_by_name(line ~264). To close it, both "not found" paths should produce an identical detail string (e.g. both raiseItemDoesNotExistwith the same generic message).Prompt To Fix With AI
agentex/src/api/routes/agent_api_keys.py, line 260-278 (link)The auth check resolves the key to an
idand passes that ID to SpiceDB, but the subsequent delete is issued by name (delete_by_agent_id_and_key_name). If the key is deleted and a new key with the same name is created between the lookup and the delete, the auth check will have evaluated the old key's ID (which the caller may have legitimately had delete rights on) but the mutation will affect the new key. The practical risk is low, but it is a real race between a non-atomic read-auth-mutate sequence. Deleting by the resolvedidinstead of by name would close it.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat(AGX1-263): migrate agent_api_keys r..." | Re-trigger Greptile