Skip to content

feat(AGX1-263): agent_api_keys route migration to FGAC (404 collapse + two-factor)#252

Draft
dm36 wants to merge 1 commit into
dhruv/agx1-272-agent-api-keys-dual-writefrom
dhruv/agx1-263-agent-api-keys-route-migration
Draft

feat(AGX1-263): agent_api_keys route migration to FGAC (404 collapse + two-factor)#252
dm36 wants to merge 1 commit into
dhruv/agx1-272-agent-api-keys-dual-writefrom
dhruv/agx1-263-agent-api-keys-route-migration

Conversation

@dm36
Copy link
Copy Markdown

@dm36 dm36 commented May 26, 2026

Related work

PR B of the agent_api_keys FGAC stack. Stacks on top of #TBD AGX1-272 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.

Stream Repo PR Purpose
A scaleapi/scale-agentex dhruv/agx1-272-agent-api-keys-dual-write dual-write api_keys to spark-authz (merge target of this PR)
B (this PR) scaleapi/scale-agentex route-layer FGAC + 404 collapse + two-factor mutations

Parent ticket: AGX1-263.

Summary

  • Wires Spark AuthZ checks into all six agent_api_keys routes.
  • Collapses every api_key-resource denial into 404 (ItemDoesNotExist) on the id and name surfaces so callers can't distinguish "present in another tenant" from "absent".
  • Filters GET /agent_api_keys to the set of api_keys the caller can read.
  • Enforces parent-agent.update explicitly on POST /agent_api_keys (no api_key resource exists yet — only enforcement surface at create time).
  • Two-factor mutations on delete: relies on SpiceDB's transitive expansion (api_key.delete = editor & parent_agent->update & tenant_gate), not a second route-layer check — same approach as feat(AGX1-275): per-RPC task permission rewire and 404/403 wrap #249.

What changed

  • src/utils/agent_api_key_authorization.py (new): _check_api_key_or_collapse_to_404(authorization, api_key_id, operation) — catches AuthorizationError, raises ItemDoesNotExist. Direct parallel of _check_task_or_collapse_to_404.
  • src/utils/authorization_shortcuts.py: DAuthorizedId now routes AgentexResourceType.api_key through 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 — so DAuthorizedName doesn't fit.

Why structural choices

Why no DAuthorizedName extension. Asher extended DAuthorizedName for tasks because tasks.name is a single globally-unique path component. agent_api_keys names 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 into DAuthorizedName's name -> 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 second authorization_service.check(agent.update, agent_id) at the route layer would only duplicate what spark-authz already enforces atomically. Mirrors #249's approach. Note (see Known unknowns): this depends on the api_key→agent parent_agent relation being populated by grant(). PR A's dual-write code path needs to wire that relation; otherwise this PR fails closed on every delete because spark-authz cannot evaluate parent_agent->update.

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.
    • 3 TestCheckApiKeyOrCollapseTo404 (allow + denied-collapse + delete-op forwarding).
    • 3 TestDAuthorizedIdApiKeyWrap (denial → 404, allow returns id, delete op propagation).
    • 2 TestNameRouteCollapse (both name handlers; verifies delete is NOT invoked when check fails).
    • 2 TestListFiltering (authorized-subset filter + None-passthrough).
    • 2 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.
  • Ruff + ruff-format clean (pre-commit hook).
  • CI for broader unit + integration suite.
  • Manual: deny an existing api_key by id/name across each surface in a dev cluster; confirm 404 on every surface.

Out of scope

  • agentex-auth spark_mapping.py updates (cross-repo work).
  • Restoring the 403/404 split for same-tenant calls once api_keys carry tenant scope (AGX1-290).

…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>
@dm36 dm36 force-pushed the dhruv/agx1-263-agent-api-keys-route-migration branch from b94f0cb to d15bc88 Compare May 27, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant