Skip to content

feat(AGX1-272): dual-write agent_api_keys to spark-authz behind FGAC flag#248

Merged
dm36 merged 9 commits into
mainfrom
dhruv/agx1-272-agent-api-keys-dual-write
Jun 2, 2026
Merged

feat(AGX1-272): dual-write agent_api_keys to spark-authz behind FGAC flag#248
dm36 merged 9 commits into
mainfrom
dhruv/agx1-272-agent-api-keys-dual-write

Conversation

@dm36

@dm36 dm36 commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Wires register_resource / deregister_resource into AgentAPIKeysUseCase.create / delete so api_keys get a SpiceDB tuple (with parent_agent edge) on create and have that tuple removed on delete. Companion to the route-migration PR (#252).

scale-agentex is intentionally decoupled from egp-api-backend. The dual-write call is unconditional from this repo. agentex-auth's per-account routing (scaleapi/agentex#353, FGAC_AGENTEX_AUTH_SPARK) decides whether the call lands on Spark AuthZ or falls back to the legacy SGP backend for the caller's account.

Linear ticket: https://linear.app/scale-epd/issue/AGX1-272/fgac-agent-api-key-dual-write-registerderegister-on-createdelete

Cross-repo stack

# Repo PR Purpose State
1 scaleapi/scaleapi #144657 sgp-authz 0.6.0 → 0.7.0; parent_resource kwarg ✅ Merged
2 scaleapi/agentex #354 agentex-auth Port + Spark adapter + HTTP routes (/v1/authz/register, /v1/authz/deregister) + api_key mapping ✅ Merged
2b scaleapi/agentex #353 agentex-auth per-account routing via FGAC_AGENTEX_AUTH_SPARK flag Open (this PR is decoupled from it — works whether #353 lands first or not)
3 (this PR) scaleapi/scale-agentex this scale-agentex use case calling register_resource(parent=agent) Ready
4 scaleapi/scale-agentex #252 route-layer FGAC + 404 collapse + two-factor mutations Stacked on this

Changes

Port + adapter

  • src/adapters/authorization/port.py — abstract register_resource(resource, parent=None) and deregister_resource(resource) on AuthorizationGateway.
  • src/adapters/authorization/adapter_agentex_authz_proxy.py — POST /v1/authz/register and /v1/authz/deregister to agentex-auth (endpoints exposed by #354).

Service layer

  • src/domain/services/authorization_service.pyregister_resource / deregister_resource service methods mirror the grant/revoke pattern (principal_context override, _bypass support, parent identity in log line).

Use case

  • src/domain/use_cases/agent_api_keys_use_case.py:
    • create calls _register_api_key_in_auth unconditionally; parent=AgentexResource.agent(agent_id) is load-bearing for the SpiceDB cascade.
    • delete (and delete_by_* variants) call _deregister_api_key_from_auth after the Postgres row is gone.
    • Register fails closed (re-raises); deregister is best-effort (logs).
    • The "no creator resolvable" guard (internal-key path with no principal user/service identity) reads from principal_context at call time — no persisted creator columns.

Tests

  • tests/integration/services/test_agent_api_key_service_dual_write.py — 6 cases. Mocks the authorization service; asserts the call sequencing inside the use case.
  • tests/integration/fixtures/integration_client.py + tests/unit/use_cases/test_agents_api_keys_use_case.py updated for the new constructor signature.

Test plan

  • 6 / 6 dual-write integration tests pass locally.
  • Parent edge contract pinned: test_create_api_key_calls_register_resource_with_parent asserts parent=AgentexResource.agent(agent.id) is passed on every register call.
  • Fail-closed on register failure preserved.
  • Best-effort deregister preserved.
  • No-creator-resolvable skip path preserved (runtime check, no persistence).
  • 4 pre-existing docker-fixture errors in test_agents_api_keys_use_case.py predate this PR (verified via git stash).
  • CI: ruff, ruff-format, alembic migration lint.
  • End-to-end against a real SpiceDB once exercised in dev via the agentex-auth routing flip.

Diff scope

10 files changed, 55 insertions(+), 368 deletions(-) — net deletion. Earlier iterations had ~370 lines of OSS-coupling that Harvey's review pruned.

Out of scope / follow-ups

  • ZedToken plumbing. If a SpiceDB consistency token use case emerges, expose it under a vendor-neutral column name (not spark_authz_zedtoken).
  • Audit / backfill for legacy api_keys created before this PR. Tracked separately.
  • Other resource types' grant → register_resource swap (task, build, deployment, schedule). Each owns its own follow-up.

Linked: AGX1-272.

Greptile Summary

This PR wires register_resource / deregister_resource into AgentAPIKeysUseCase.create and the three delete methods, so every agent_api_key gets a SpiceDB tuple (with a parent_agent edge) on creation and has it removed on deletion. The dual-write is unconditional in this repo; per-account routing to Spark AuthZ vs. the legacy SGP backend is delegated to agentex-auth.

  • Port + adapter: AuthorizationGateway gains abstract register_resource / deregister_resource; AgentexAuthorizationProxy implements them via POST to /v1/authz/register and /v1/authz/deregister.
  • Service layer: AuthorizationService.register_resource and deregister_resource mirror the existing grant/revoke pattern — _bypass() guard, principal_context override, structured log line.
  • Use case: create calls _register_api_key_in_auth (fail-closed, before the Postgres write) with parent=AgentexResource.agent(agent_id). All three delete paths call _deregister_api_key_from_auth (best-effort, after the DB delete) using the entity ID retrieved by a pre-fetch. A no-creator guard skips registration when neither user_id nor service_account_id is resolvable on the principal.

Confidence Score: 5/5

Safe to merge. The dual-write is unconditional and well-guarded: register fails closed (Postgres row never written on Spark failure), deregister is best-effort (delete completes regardless), and the no-creator guard prevents registration without an owner identity.

The create path correctly sequences auth registration before the DB write and re-raises on failure. The delete paths pre-fetch the entity ID, complete the DB delete, then attempt deregistration with swallowed errors. Bypass semantics and principal-context plumbing are consistent with the existing grant/revoke pattern.

No files require special attention. The integration test file has a minor coverage gap for delete_by_agent_name_and_key_name, but the code itself is correct.

Important Files Changed

Filename Overview
agentex/src/domain/use_cases/agent_api_keys_use_case.py Adds _register_api_key_in_auth (fail-closed, pre-DB) and _deregister_api_key_from_auth (best-effort, post-DB) helpers; all three delete methods updated to pre-fetch the entity ID and call deregister. Logic is sound; no blocking issues.
agentex/src/domain/services/authorization_service.py Adds register_resource and deregister_resource service methods following the established grant/revoke pattern; bypass and principal-override semantics are consistent with existing methods.
agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py Adds register_resource (POST /v1/authz/register) and deregister_resource (POST /v1/authz/deregister) to the proxy adapter; payload serialization mirrors the existing grant/revoke pattern.
agentex/src/adapters/authorization/port.py Adds abstract register_resource and deregister_resource to AuthorizationGateway; docstrings clearly explain the SpiceDB cascade contract.
agentex/tests/integration/services/test_agent_api_key_service_dual_write.py Six new integration tests covering the dual-write sequencing (register-before-create, fail-closed on register failure, best-effort deregister, no-creator skip, delete-by-name deregister). Missing coverage for the delete_by_agent_name_and_key_name path.
agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py Updated fixture to inject a properly mocked authorization_service (with AsyncMock for both register_resource and deregister_resource), matching the integration client setup.
agentex/tests/integration/fixtures/integration_client.py Adds a noop_authorization_service mock with all required AsyncMock methods and passes it to AgentAPIKeysUseCase; consistent with the unit test fixture pattern.

Sequence Diagram

sequenceDiagram
    participant C as Caller
    participant UC as AgentAPIKeysUseCase
    participant AS as AuthorizationService
    participant GW as AgentexAuthorizationProxy
    participant DB as Postgres
    participant AX as agentex-auth

    Note over UC,AX: create()
    C->>UC: create(name, agent_id, api_key_type, api_key)
    UC->>DB: agent_repo.get(agent_id)
    DB-->>UC: AgentEntity
    UC->>UC: _register_api_key_in_auth(api_key_id, agent_id)
    UC->>AS: "register_resource(resource=api_key, parent=agent)"
    AS->>GW: register_resource(principal, resource, parent)
    GW->>AX: POST /v1/authz/register
    AX-->>GW: 200 OK
    GW-->>AS: None
    AS-->>UC: None
    UC->>DB: agent_api_key_repo.create(entity)
    DB-->>UC: AgentAPIKeyEntity
    UC-->>C: AgentAPIKeyEntity

    Note over UC,AX: delete() / delete_by_*()
    C->>UC: "delete(id) / delete_by_*(...)"
    UC->>DB: agent_api_key_repo.get(id)
    DB-->>UC: "AgentAPIKeyEntity (or ItemDoesNotExist -> return)"
    UC->>DB: agent_api_key_repo.delete(id)
    DB-->>UC: None
    UC->>UC: _deregister_api_key_from_auth(api_key_id)
    UC->>AS: "deregister_resource(resource=api_key)"
    AS->>GW: deregister_resource(principal, resource)
    GW->>AX: POST /v1/authz/deregister
    AX-->>GW: 200 OK (failure: logged, ignored)
    GW-->>AS: None
    AS-->>UC: None
    UC-->>C: None
Loading

Comments Outside Diff (1)

  1. agentex/tests/integration/services/test_agent_api_key_service_dual_write.py, line 703-727 (link)

    P2 Missing test for grant-succeeds-but-DB-create-fails (orphan Spark tuple)

    The suite covers grant failure preventing the DB write, but not the reverse: grant succeeding followed by repo.create raising (e.g., a duplicate key or transient DB error). In that case a Spark tuple exists for an api_key_id that never lands in Postgres. Adding a test for this case would make the documented known-limitation explicit and guard against accidental regression if compensating logic is added later.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: agentex/tests/integration/services/test_agent_api_key_service_dual_write.py
    Line: 703-727
    
    Comment:
    **Missing test for grant-succeeds-but-DB-create-fails (orphan Spark tuple)**
    
    The suite covers `grant` failure preventing the DB write, but not the reverse: `grant` succeeding followed by `repo.create` raising (e.g., a duplicate key or transient DB error). In that case a Spark tuple exists for an `api_key_id` that never lands in Postgres. Adding a test for this case would make the documented known-limitation explicit and guard against accidental regression if compensating logic is added later.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Cursor Fix in Claude Code Fix in Codex

Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
agentex/tests/integration/services/test_agent_api_key_service_dual_write.py:260-291
**`delete_by_agent_name_and_key_name` dual-write path has no test**

`delete_by_agent_id_and_key_name` has `test_delete_by_agent_id_and_key_name_revokes_existing`, but the parallel method `delete_by_agent_name_and_key_name` — which follows the same pre-fetch → delete → deregister pattern — has no coverage in this suite. If a regression breaks the `get_by_agent_name_and_key_name` lookup or the conditional deregister there, it would go undetected. A mirrored test (analogous to the existing one but using agent name instead of agent ID) would close the gap.

Reviews (9): Last reviewed commit: "Merge branch 'main' into dhruv/agx1-272-..." | Re-trigger Greptile

@dm36 dm36 marked this pull request as ready for review May 26, 2026 21:31
@dm36 dm36 requested a review from a team as a code owner May 26, 2026 21:31
Comment thread agentex/src/domain/use_cases/agent_api_keys_use_case.py Outdated
Comment thread agentex/src/domain/use_cases/agent_api_keys_use_case.py Outdated
dm36 added a commit that referenced this pull request May 26, 2026
Closes the parent_agent cascade gap surfaced on scaleapi/agentex#354.
The api_key dual-write (AGX1-272, PR #248) currently calls grant() which
writes the owner edge in SpiceDB but NOT the parent_agent edge. The
agent_api_key schema requires `read = ... & parent_agent->read & ...`,
so every downstream read/update fails closed without that edge.

This PR adds register_resource/deregister_resource (Port + adapter + service)
and swaps the api_keys use case from grant→register_resource with
parent=AgentexResource.agent(agent_id). Now the owner edge and parent_agent
edge are written atomically.

Stack:
- scaleapi/scaleapi#144657 — sgp-authz 0.7.0 (parent_resource kwarg).
- scaleapi/agentex#355 — agentex-auth Port + adapter + HTTP routes.
- #248 — original AGX1-272 dual-write (this stacks on it).
- THIS PR — extends #248 to use the parent-aware path.

Changes:
- Port: abstract register_resource(resource, parent=None) and
  deregister_resource(resource).
- Adapter proxy: POST /v1/authz/register and /v1/authz/deregister.
- Service: mirror existing grant/revoke pattern (principal_context override,
  _bypass support, parent in log line for cascade debugging).
- Use case: swap grant→register_resource passing parent=agent;
  swap revoke→deregister_resource. except Exception wrappers preserved
  (fail-closed on register, best-effort on deregister).
- Tests: rename mocks to register_resource/deregister_resource; assert the
  parent edge is passed correctly.

Test plan:
- pytest agentex/tests/integration/services/test_agent_api_key_service_dual_write.py
  → 8 / 8 pass.
- New test ``test_create_api_key_calls_grant_when_flag_on`` asserts
  parent.type == AgentexResourceType.agent and parent.selector == agent.id.

Other resource types' grant→register_resource swap is out of scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dm36 dm36 marked this pull request as draft May 27, 2026 17:27
dm36 and others added 2 commits May 28, 2026 10:28
…AGENT_API_KEYS_DUAL_WRITE flag

Mirrors the AGX1-274 task dual-write pattern (PR #246) for agent_api_keys.

- Adds creator_user_id / creator_service_account_id / spark_authz_zedtoken
  columns to agent_api_keys, with CHECK constraint and concurrent indexes.
- On create, when FGAC_AGENT_API_KEYS_DUAL_WRITE is enabled for the caller's
  account, calls authorization_service.grant(AgentexResource.api_key(id))
  BEFORE the Postgres write. Grant failure aborts the create.
- On delete, best-effort revoke after the Postgres delete. Failures are
  logged but do not block the delete.
- Adds AgentexResourceType.api_key and AgentexResource.api_key(...) factory.
- Creates src/utils/feature_flags.py with both FGAC_TASKS_DUAL_WRITE and
  FGAC_AGENT_API_KEYS_DUAL_WRITE (file does not exist on main yet; if PR #246
  lands first this becomes a rebase concern).

Structural divergence from tasks: agent_api_keys have no service layer, so
the dual-write logic lives in AgentAPIKeysUseCase rather than a separate
service. This keeps the call site simple and avoids inventing a new layer.

Route layer (read-side auth checks) is out of scope; that's PR B (AGX1-273).
agentex-auth spark_mapping.py update is a sibling-repo concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the parent_agent cascade gap surfaced on scaleapi/agentex#354.
The api_key dual-write (AGX1-272, PR #248) currently calls grant() which
writes the owner edge in SpiceDB but NOT the parent_agent edge. The
agent_api_key schema requires `read = ... & parent_agent->read & ...`,
so every downstream read/update fails closed without that edge.

This PR adds register_resource/deregister_resource (Port + adapter + service)
and swaps the api_keys use case from grant→register_resource with
parent=AgentexResource.agent(agent_id). Now the owner edge and parent_agent
edge are written atomically.

Stack:
- scaleapi/scaleapi#144657 — sgp-authz 0.7.0 (parent_resource kwarg).
- scaleapi/agentex#355 — agentex-auth Port + adapter + HTTP routes.
- #248 — original AGX1-272 dual-write (this stacks on it).
- THIS PR — extends #248 to use the parent-aware path.

Changes:
- Port: abstract register_resource(resource, parent=None) and
  deregister_resource(resource).
- Adapter proxy: POST /v1/authz/register and /v1/authz/deregister.
- Service: mirror existing grant/revoke pattern (principal_context override,
  _bypass support, parent in log line for cascade debugging).
- Use case: swap grant→register_resource passing parent=agent;
  swap revoke→deregister_resource. except Exception wrappers preserved
  (fail-closed on register, best-effort on deregister).
- Tests: rename mocks to register_resource/deregister_resource; assert the
  parent edge is passed correctly.

Test plan:
- pytest agentex/tests/integration/services/test_agent_api_key_service_dual_write.py
  → 8 / 8 pass.
- New test ``test_create_api_key_calls_grant_when_flag_on`` asserts
  parent.type == AgentexResourceType.agent and parent.selector == agent.id.

Other resource types' grant→register_resource swap is out of scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dm36 dm36 marked this pull request as ready for review May 28, 2026 17:29
@dm36 dm36 force-pushed the dhruv/agx1-272-agent-api-keys-dual-write branch from 9668f1a to e72df68 Compare May 28, 2026 17:29
…L_WRITE

Per team discussion: rather than maintain a parallel env-var flag system
in scale-agentex, route api_key dual-write flag checks through
egp-api-backend's existing flag service. One source of truth across services,
single flip surface for ops, fewer per-env env-var allowlists to keep in sync.

Changes:

- EnvVarKeys.EGP_API_BACKEND_URL — new env var for the egp-api-backend
  base URL. Used by the new HTTP-backed flag provider.

- FeatureFlagProvider rewritten as an HTTP client of egp-api-backend's
  GET /feature-flag/{id} endpoint:
    * Forwards x-api-key / x-user-id / x-service-account-id /
      x-selected-account-id from the caller's principal_context so the
      endpoint's REQUIRE_IDENTITY_AND_OPTIONAL_ACCOUNT policy admits the
      request.
    * Coerces the response's `value` field to bool.
    * Fails closed to False on any error (config missing, no identity,
      non-2xx, transport failure, JSON parse failure) — the legacy
      no-Spark code path is the safe default.
    * `is_enabled` is now async (HTTP call). Signature is
      `is_enabled(name, *, principal_context, account_id)`.

- AgentAPIKeysUseCase: both call sites now await is_enabled and pass
  principal_context. _deregister grabs principal_context from
  self.authorization_service.

- Test fixtures: mock FeatureFlagProvider directly (Mock with
  is_enabled = AsyncMock(return_value=flag_on)) so dual-write tests stay
  hermetic. The pre-existing FeatureFlagProvider() no-arg constructions
  in test_agents_api_keys_use_case.py and integration_client.py now pass
  egp_api_backend_url=None (provider returns False without it, matching
  the prior "flag never enabled in unit tests" behavior).

Out of scope:

- Migrating Asher's FGAC_TASKS_DUAL_WRITE flag check off env vars.
  That's task-team-owned and we leave their existing pattern alone per
  the team discussion (new-work-only).
- Caching the flag response. Each is_enabled is a fresh HTTP call.
  Egp-api-backend's flag endpoint is fast and the caller paths are
  already crossing the network for the actual register/deregister, so
  one extra round-trip is acceptable for now. Add caching later if
  load profiling shows it matters.

Test plan:

- uv run pytest agentex/tests/integration/services/test_agent_api_key_service_dual_write.py — 8/8 pass.
- Existing 4 unrelated test_agents_api_keys_use_case.py docker-fixture
  errors predate this commit (verified via `git stash`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread agentex/src/utils/feature_flags.py Outdated
Comment thread agentex/src/domain/entities/agent_api_keys.py Outdated
Comment thread agentex/src/domain/entities/agent_api_keys.py Outdated
Three rounds of Harvey's review feedback together pointed at the same
concern: scale-agentex (OSS) should not be coupled to egp-api-backend
(proprietary) feature-flag service or Spark-specific schema concepts.

Changes:

- Drop FeatureFlagProvider and the egp-api-backend HTTP query entirely.
  scale-agentex now calls register_resource/deregister_resource
  unconditionally. agentex-auth's per-account routing
  (FGAC_AGENTEX_AUTH_SPARK, scaleapi/agentex#353) decides whether the
  call lands on Spark AuthZ or falls back to legacy SGP for the caller's
  account.

- Drop the creator_user_id / creator_service_account_id columns from the
  agent_api_keys table. They were only used as a runtime guard inside
  the auth-registration helper. Moved that check inline.

- Drop the spark_authz_zedtoken column. The name leaked SpiceDB-specific
  terminology into the OSS schema, and the column was always None.

- Delete the Alembic migration entirely. migration_history.txt
  re-pointed to head=6c942325c828.

- Rename helper methods: _register_api_key_in_spark_authz ->
  _register_api_key_in_auth, same for deregister.

Test plan: 6/6 dual-write integration tests pass. Pre-existing
docker-fixture errors in test_agents_api_keys_use_case.py unrelated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread agentex/src/domain/use_cases/agent_api_keys_use_case.py
Per @jenniechung review: delete(id) used to call deregister unconditionally,
which wastes an auth round-trip when the id doesn't exist. The two
delete_by_* methods already use pre-fetch + `if existing is not None`;
delete() now matches that pattern via try/except ItemDoesNotExist.

Also tightened the unconditional-register comment from 5 lines to 2.

Tests:
- Fixture now defaults repo.get() to a stub entity so existing delete tests
  flow through the deregister path; new helper `_stub_api_key`.
- New test_delete_api_key_skips_deregister_when_row_does_not_exist asserts
  deregister.assert_not_called() when repo.get raises ItemDoesNotExist.
- 7/7 dual-write tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py
…t fixture

Per reviewer: the agent_api_keys_use_case fixture had `grant` and `revoke`
set up as AsyncMocks but not `register_resource` / `deregister_resource`.
The deregister path silently swallowed the resulting TypeError via its
`except Exception` handler, so delete tests passed without exercising the
auth-side call. Matches the integration_client.py fixture shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread agentex/src/api/routes/agent_api_keys.py Outdated
api_key_type=api_key_type,
api_key=api_key,
)
return await self.agent_api_key_repo.create(item=agent_api_key)

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.

Should we follow the egp-api-backend's pattern and add compensating deregister if repo.create fails?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

followed asher's approach here with tasks with not compensating deregister if repo.create fails for consistency- i can update tho

Comment thread agentex/src/domain/use_cases/agent_api_keys_use_case.py
dm36 and others added 2 commits June 1, 2026 11:36
agentex-auth derives account_id from the authenticated principal on its
side, so threading it through routes and use-case calls in OSS
scale-agentex is dead weight — and leaks the SGP-specific field name.

Drop:
- account_id extraction (getattr on principal_context) in all three
  api_keys route handlers; drop the now-unused DAuthorizationService
  injection from create_api_key/delete_agent_api_key.
- account_id parameter on create, delete, delete_by_agent_id_and_key_name,
  delete_by_agent_name_and_key_name, _register_api_key_in_auth, and
  _deregister_api_key_from_auth.
- account_id entries from log extras (it was only ever log context — no
  auth call ever consumed it).
- account_id kwargs at use-case call sites in the dual-write test.

Keep the _principal(account_id=...) test helper — it models the
principal shape and Harvey's feedback is about production OSS surface,
not test mocks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per Harvey's nit: when the pre-fetch raises ItemDoesNotExist, skip both
the repo.delete (was issuing a wasted DB round-trip for a no-op) and
the deregister. Same HTTP response from the route either way.

Test renamed and updated: now asserts neither repo.delete nor deregister
is called when the row doesn't exist (previously asserted repo.delete
was called).

Note: the delete_by_* variants have the symmetric pattern (unconditional
repo.delete_by_* even when the pre-fetch returns None), but they
predate this PR and Harvey's comment was scoped to delete. Leaving
those for now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dm36 dm36 enabled auto-merge (squash) June 2, 2026 16:32
@dm36 dm36 merged commit 7c18528 into main Jun 2, 2026
30 checks passed
@dm36 dm36 deleted the dhruv/agx1-272-agent-api-keys-dual-write branch June 2, 2026 16:37
dm36 added a commit that referenced this pull request Jun 2, 2026
…+ two-factor) (#252)

## Summary

Wires Spark AuthZ into all six `agent_api_keys` routes. Mirrors Asher's
task-route pattern from
[#249](#249).

- All denials on the api_key resource collapse to `404` so callers can't
probe cross-tenant existence.
- `GET` list filters to api_keys the caller can read (id filter pushed
into the repo for correct pagination).
- `POST` does an explicit `agent.update` check on the parent — only
enforcement surface at create time, since no api_key resource exists
yet.
- Mutations (`delete`) rely on SpiceDB's transitive `api_key.delete =
editor & parent_agent->update & tenant_gate` expansion. The
`parent_agent` edge is populated by
[#248](#248
`register_resource(parent=agent)`.

## Stack

| Repo | PR | State |
|---|---|---|
| scaleapi/scaleapi |
[#144657](scaleapi/scaleapi#144657) — sgp-authz
0.7.0 | ✅ Merged |
| scaleapi/agentex |
[#354](scaleapi/agentex#354) — `api_key` mapping
| ✅ Merged |
| scaleapi/scale-agentex |
[#248](#248) — dual-write
| ✅ Merged |
| scaleapi/scale-agentex | this PR — route-layer FGAC | Ready |

Linear: [AGX1-263](https://linear.app/scale-epd/issue/AGX1-263).

## Tests

- `tests/unit/api/test_agent_api_keys_authz.py` — 12 tests covering the
collapse helper, `DAuthorizedId` routing, name-route inline collapse,
list filtering, and create-parent-check.

## Out of scope

- Restoring the 403/404 split once api_keys carry tenant scope
([AGX1-290](https://linear.app/scale-epd/issue/AGX1-290)).

<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR wires Spark AuthZ (FGAC) into all six `agent_api_keys` routes,
mirroring the task-route pattern from #249. All authorization denials
collapse to `404` to prevent cross-tenant key-name probing, and the
delete-by-name route now resolves name→id before checking auth (closing
the TOCTOU window flagged in a prior review).

- **All six routes gated**: `POST` checks `agent.update` on the parent
agent (no api_key resource exists yet); `GET /{id}` and `DELETE /{id}`
use `DAuthorizedId`; `GET /name/{name}` and `DELETE /name/{name}` use
the inline `_check_api_key_or_collapse_to_404` helper; `GET /` uses
`DAuthorizedResourceIds` with the id list pushed into the SQL filter for
correct pagination.
- **404 collapse**: Both \"row absent\" and \"row present but denied\"
paths on name routes now raise
`ItemDoesNotExist(API_KEY_NOT_FOUND_MESSAGE)`, making the bodies
byte-for-byte identical and closing the existence-leak vector.
- **`AgentAPIKeysUseCase.list`** gains an optional `id` filter with an
explicit empty-list short-circuit to prevent the base repo from
converting `id=[]` into an unfiltered query.

<details><summary><h3>Confidence Score: 5/5</h3></summary>

The FGAC wiring is correct across all six routes; the 404-collapse and
TOCTOU fixes flagged in prior reviews have been addressed.

The authorization logic is sound: denials are consistently collapsed to
404 with identical bodies, delete-by-name now deletes by resolved ID
rather than by name, and the empty-list short-circuit prevents an
accidental unfiltered query. The only findings are a misleading (but
vacuous) test assertion and an untyped parameter — neither affects
runtime behavior.

The test assertion in
test_delete_by_name_handler_collapses_denial_to_404 checks a removed
method rather than the one now used for deletion; worth a quick fix
before the tests are relied upon for regression coverage.
</details>


<h3>Important Files Changed</h3>




| Filename | Overview |
|----------|----------|
| agentex/src/api/routes/agent_api_keys.py | All six routes now gated by
FGAC; denials collapse to 404; delete-by-name resolves name→id before
auth check, closing the TOCTOU window; absent and denied 404 bodies are
byte-for-byte identical via API_KEY_NOT_FOUND_MESSAGE. |
| agentex/src/utils/agent_api_key_authorization.py | New collapse helper
correctly catches AuthorizationError and raises ItemDoesNotExist with a
fixed message; minor: authorization parameter is untyped. |
| agentex/src/utils/authorization_shortcuts.py | DAuthorizedId gains an
api_key branch that delegates to the collapse helper; formatting-only
change to DAuthorizedBodyId; no logic issues. |
| agentex/src/domain/use_cases/agent_api_keys_use_case.py | list() gains
optional id filter pushed to repo layer; empty-list short-circuit
correctly prevents unfiltered query when caller has no authorized keys.
|
| agentex/tests/unit/api/test_agent_api_keys_authz.py | 12 tests
covering all major FGAC paths;
test_delete_by_name_handler_collapses_denial_to_404 asserts the removed
delete_by_agent_id_and_key_name is not called rather than the new delete
method, making the delete-not-invoked assertion vacuous. |
| agentex/src/api/schemas/authorization_types.py | Formatting-only
change to the schedule() method signature; no logic changes. |

</details>



<details><summary><h3>Sequence Diagram</h3></summary>

```mermaid
sequenceDiagram
    participant Client
    participant Route as agent_api_keys route
    participant SpiceDB as SpiceDB (AuthZ)
    participant DB as Postgres (repo)

    Note over Route,DB: GET /{id} / DELETE /{id} — DAuthorizedId dep
    Client->>Route: "GET /agent_api_keys/{id}"
    Route->>SpiceDB: "check(api_key:{id}, read)"
    alt denied
        SpiceDB-->>Route: AuthorizationError
        Route-->>Client: 404 ItemDoesNotExist
    else allowed
        SpiceDB-->>Route: OK
        Route->>DB: get(id)
        DB-->>Route: entity
        Route-->>Client: 200 AgentAPIKey
    end

    Note over Route,DB: GET /name/{name} — inline collapse
    Client->>Route: "GET /agent_api_keys/name/{name}"
    Route->>DB: get_by_agent_id_and_name(...)
    alt absent
        DB-->>Route: None
        Route-->>Client: 404 Agent api_key not found.
    else present
        DB-->>Route: entity (with id)
        Route->>SpiceDB: "check(api_key:{id}, read)"
        alt denied
            SpiceDB-->>Route: AuthorizationError
            Route-->>Client: 404 Agent api_key not found.
        else allowed
            SpiceDB-->>Route: OK
            Route-->>Client: 200 AgentAPIKey
        end
    end

    Note over Route,DB: DELETE /name/{name} — resolve→check→delete
    Client->>Route: "DELETE /agent_api_keys/name/{name}"
    Route->>DB: get_by_agent_id_and_name(...)
    alt absent
        DB-->>Route: None
        Route-->>Client: 404 Agent api_key not found.
    else present
        DB-->>Route: entity (with id)
        Route->>SpiceDB: "check(api_key:{id}, delete)"
        alt denied
            SpiceDB-->>Route: AuthorizationError
            Route-->>Client: 404 Agent api_key not found.
        else allowed
            SpiceDB-->>Route: OK
            Route->>DB: "delete(id=entity.id)"
            Route-->>Client: 200 deleted
        end
    end

    Note over Route,DB: POST / — parent agent.update check
    Client->>Route: POST /agent_api_keys
    Route->>DB: agent_use_case.get(...)
    Route->>SpiceDB: "check(agent:{id}, update)"
    alt denied
        SpiceDB-->>Route: AuthorizationError 403
        Route-->>Client: 403
    else allowed
        SpiceDB-->>Route: OK
        Route->>DB: create api_key
        Route-->>Client: 201 CreateAPIKeyResponse
    end
```
</details>


<!-- greptile_failed_comments -->
<details open><summary><h3>Comments Outside Diff (2)</h3></summary>

1. `agentex/src/api/routes/agent_api_keys.py`, line 163-179
([link](https://github.com/scaleapi/scale-agentex/blob/4335ca721893fc121ba8209b05ee1dcc6ef9d7b5/agentex/src/api/routes/agent_api_keys.py#L163-L179))

<a href="#"><img alt="P1"
src="https://greptile-static-assets.s3.amazonaws.com/badges/p1.svg?v=9"
align="top"></a> <a href="#"><img alt="security"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Security.svg?v=2"
align="top"></a> **Distinguishable 404 bodies defeat the existence-leak
collapse**

The name routes produce two structurally different 404 responses
depending on whether the key exists in the database. If the key is
absent, `HTTPException` is raised with `"Agent api_key '{name}' not
found for agent ID {agent.id}"`. If the key exists but the caller is
denied, `ItemDoesNotExist` is raised with `"Item with id '{api_key_id}'
does not exist."`. Both return HTTP 404, but a cross-tenant caller who
can resolve an `agent_id` can probe for key-name existence by comparing
the response body — exactly the information leak the collapse is meant
to prevent. The same issue exists in `delete_agent_api_key_by_name`
(line ~264). To close it, both "not found" paths should produce an
identical detail string (e.g. both raise `ItemDoesNotExist` with the
same generic message).

   <details><summary>Prompt To Fix With AI</summary>

   `````markdown
   This is a comment left during a code review.
   Path: agentex/src/api/routes/agent_api_keys.py
   Line: 163-179

   Comment:
   **Distinguishable 404 bodies defeat the existence-leak collapse**

The name routes produce two structurally different 404 responses
depending on whether the key exists in the database. If the key is
absent, `HTTPException` is raised with `"Agent api_key '{name}' not
found for agent ID {agent.id}"`. If the key exists but the caller is
denied, `ItemDoesNotExist` is raised with `"Item with id '{api_key_id}'
does not exist."`. Both return HTTP 404, but a cross-tenant caller who
can resolve an `agent_id` can probe for key-name existence by comparing
the response body — exactly the information leak the collapse is meant
to prevent. The same issue exists in `delete_agent_api_key_by_name`
(line ~264). To close it, both "not found" paths should produce an
identical detail string (e.g. both raise `ItemDoesNotExist` with the
same generic message).

   How can I resolve this? If you propose a fix, please make it concise.
   `````
   </details>

<a
href="https://app.greptile.com/api/ide/cursor?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20163-179%0A%0AComment%3A%0A**Distinguishable%20404%20bodies%20defeat%20the%20existence-leak%20collapse**%0A%0AThe%20name%20routes%20produce%20two%20structurally%20different%20404%20responses%20depending%20on%20whether%20the%20key%20exists%20in%20the%20database.%20If%20the%20key%20is%20absent%2C%20%60HTTPException%60%20is%20raised%20with%20%60%22Agent%20api_key%20'%7Bname%7D'%20not%20found%20for%20agent%20ID%20%7Bagent.id%7D%22%60.%20If%20the%20key%20exists%20but%20the%20caller%20is%20denied%2C%20%60ItemDoesNotExist%60%20is%20raised%20with%20%60%22Item%20with%20id%20'%7Bapi_key_id%7D'%20does%20not%20exist.%22%60.%20Both%20return%20HTTP%20404%2C%20but%20a%20cross-tenant%20caller%20who%20can%20resolve%20an%20%60agent_id%60%20can%20probe%20for%20key-name%20existence%20by%20comparing%20the%20response%20body%20%E2%80%94%20exactly%20the%20information%20leak%20the%20collapse%20is%20meant%20to%20prevent.%20The%20same%20issue%20exists%20in%20%60delete_agent_api_key_by_name%60%20%28line%20~264%29.%20To%20close%20it%2C%20both%20%22not%20found%22%20paths%20should%20produce%20an%20identical%20detail%20string%20%28e.g.%20both%20raise%20%60ItemDoesNotExist%60%20with%20the%20same%20generic%20message%29.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&pr=252&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursorDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"><img
alt="Fix in Cursor"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"
height="20"></picture></a> <a
href="https://app.greptile.com/ide/claude-code?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20163-179%0A%0AComment%3A%0A**Distinguishable%20404%20bodies%20defeat%20the%20existence-leak%20collapse**%0A%0AThe%20name%20routes%20produce%20two%20structurally%20different%20404%20responses%20depending%20on%20whether%20the%20key%20exists%20in%20the%20database.%20If%20the%20key%20is%20absent%2C%20%60HTTPException%60%20is%20raised%20with%20%60%22Agent%20api_key%20'%7Bname%7D'%20not%20found%20for%20agent%20ID%20%7Bagent.id%7D%22%60.%20If%20the%20key%20exists%20but%20the%20caller%20is%20denied%2C%20%60ItemDoesNotExist%60%20is%20raised%20with%20%60%22Item%20with%20id%20'%7Bapi_key_id%7D'%20does%20not%20exist.%22%60.%20Both%20return%20HTTP%20404%2C%20but%20a%20cross-tenant%20caller%20who%20can%20resolve%20an%20%60agent_id%60%20can%20probe%20for%20key-name%20existence%20by%20comparing%20the%20response%20body%20%E2%80%94%20exactly%20the%20information%20leak%20the%20collapse%20is%20meant%20to%20prevent.%20The%20same%20issue%20exists%20in%20%60delete_agent_api_key_by_name%60%20%28line%20~264%29.%20To%20close%20it%2C%20both%20%22not%20found%22%20paths%20should%20produce%20an%20identical%20detail%20string%20%28e.g.%20both%20raise%20%60ItemDoesNotExist%60%20with%20the%20same%20generic%20message%29.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=scaleapi%2Fscale-agentex&pr=252&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaudeDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"><img
alt="Fix in Claude Code"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"
height="20"></picture></a> <a
href="https://chatgpt.com/codex/deeplink?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%0A%0AThis%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20163-179%0A%0AComment%3A%0A**Distinguishable%20404%20bodies%20defeat%20the%20existence-leak%20collapse**%0A%0AThe%20name%20routes%20produce%20two%20structurally%20different%20404%20responses%20depending%20on%20whether%20the%20key%20exists%20in%20the%20database.%20If%20the%20key%20is%20absent%2C%20%60HTTPException%60%20is%20raised%20with%20%60%22Agent%20api_key%20'%7Bname%7D'%20not%20found%20for%20agent%20ID%20%7Bagent.id%7D%22%60.%20If%20the%20key%20exists%20but%20the%20caller%20is%20denied%2C%20%60ItemDoesNotExist%60%20is%20raised%20with%20%60%22Item%20with%20id%20'%7Bapi_key_id%7D'%20does%20not%20exist.%22%60.%20Both%20return%20HTTP%20404%2C%20but%20a%20cross-tenant%20caller%20who%20can%20resolve%20an%20%60agent_id%60%20can%20probe%20for%20key-name%20existence%20by%20comparing%20the%20response%20body%20%E2%80%94%20exactly%20the%20information%20leak%20the%20collapse%20is%20meant%20to%20prevent.%20The%20same%20issue%20exists%20in%20%60delete_agent_api_key_by_name%60%20%28line%20~264%29.%20To%20close%20it%2C%20both%20%22not%20found%22%20paths%20should%20produce%20an%20identical%20detail%20string%20%28e.g.%20both%20raise%20%60ItemDoesNotExist%60%20with%20the%20same%20generic%20message%29.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise."><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodexDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"><img
alt="Fix in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"
height="20"></picture></a>

2. `agentex/src/api/routes/agent_api_keys.py`, line 260-278
([link](https://github.com/scaleapi/scale-agentex/blob/4335ca721893fc121ba8209b05ee1dcc6ef9d7b5/agentex/src/api/routes/agent_api_keys.py#L260-L278))

<a href="#"><img alt="P2"
src="https://greptile-static-assets.s3.amazonaws.com/badges/p2.svg?v=9"
align="top"></a> **TOCTOU window between auth check and delete-by-name**

The auth check resolves the key to an `id` and 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 resolved `id`
instead of by name would close it.

   <details><summary>Prompt To Fix With AI</summary>

   `````markdown
   This is a comment left during a code review.
   Path: agentex/src/api/routes/agent_api_keys.py
   Line: 260-278

   Comment:
   **TOCTOU window between auth check and delete-by-name**

The auth check resolves the key to an `id` and 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 resolved `id`
instead of by name would close it.

   How can I resolve this? If you propose a fix, please make it concise.
   `````
   </details>

<a
href="https://app.greptile.com/api/ide/cursor?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20260-278%0A%0AComment%3A%0A**TOCTOU%20window%20between%20auth%20check%20and%20delete-by-name**%0A%0AThe%20auth%20check%20resolves%20the%20key%20to%20an%20%60id%60%20and%20passes%20that%20ID%20to%20SpiceDB%2C%20but%20the%20subsequent%20delete%20is%20issued%20by%20name%20%28%60delete_by_agent_id_and_key_name%60%29.%20If%20the%20key%20is%20deleted%20and%20a%20new%20key%20with%20the%20same%20name%20is%20created%20between%20the%20lookup%20and%20the%20delete%2C%20the%20auth%20check%20will%20have%20evaluated%20the%20old%20key's%20ID%20%28which%20the%20caller%20may%20have%20legitimately%20had%20delete%20rights%20on%29%20but%20the%20mutation%20will%20affect%20the%20new%20key.%20The%20practical%20risk%20is%20low%2C%20but%20it%20is%20a%20real%20race%20between%20a%20non-atomic%20read-auth-mutate%20sequence.%20Deleting%20by%20the%20resolved%20%60id%60%20instead%20of%20by%20name%20would%20close%20it.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&pr=252&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursorDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"><img
alt="Fix in Cursor"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"
height="20"></picture></a> <a
href="https://app.greptile.com/ide/claude-code?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20260-278%0A%0AComment%3A%0A**TOCTOU%20window%20between%20auth%20check%20and%20delete-by-name**%0A%0AThe%20auth%20check%20resolves%20the%20key%20to%20an%20%60id%60%20and%20passes%20that%20ID%20to%20SpiceDB%2C%20but%20the%20subsequent%20delete%20is%20issued%20by%20name%20%28%60delete_by_agent_id_and_key_name%60%29.%20If%20the%20key%20is%20deleted%20and%20a%20new%20key%20with%20the%20same%20name%20is%20created%20between%20the%20lookup%20and%20the%20delete%2C%20the%20auth%20check%20will%20have%20evaluated%20the%20old%20key's%20ID%20%28which%20the%20caller%20may%20have%20legitimately%20had%20delete%20rights%20on%29%20but%20the%20mutation%20will%20affect%20the%20new%20key.%20The%20practical%20risk%20is%20low%2C%20but%20it%20is%20a%20real%20race%20between%20a%20non-atomic%20read-auth-mutate%20sequence.%20Deleting%20by%20the%20resolved%20%60id%60%20instead%20of%20by%20name%20would%20close%20it.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=scaleapi%2Fscale-agentex&pr=252&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaudeDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"><img
alt="Fix in Claude Code"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"
height="20"></picture></a> <a
href="https://chatgpt.com/codex/deeplink?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%0A%0AThis%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20260-278%0A%0AComment%3A%0A**TOCTOU%20window%20between%20auth%20check%20and%20delete-by-name**%0A%0AThe%20auth%20check%20resolves%20the%20key%20to%20an%20%60id%60%20and%20passes%20that%20ID%20to%20SpiceDB%2C%20but%20the%20subsequent%20delete%20is%20issued%20by%20name%20%28%60delete_by_agent_id_and_key_name%60%29.%20If%20the%20key%20is%20deleted%20and%20a%20new%20key%20with%20the%20same%20name%20is%20created%20between%20the%20lookup%20and%20the%20delete%2C%20the%20auth%20check%20will%20have%20evaluated%20the%20old%20key's%20ID%20%28which%20the%20caller%20may%20have%20legitimately%20had%20delete%20rights%20on%29%20but%20the%20mutation%20will%20affect%20the%20new%20key.%20The%20practical%20risk%20is%20low%2C%20but%20it%20is%20a%20real%20race%20between%20a%20non-atomic%20read-auth-mutate%20sequence.%20Deleting%20by%20the%20resolved%20%60id%60%20instead%20of%20by%20name%20would%20close%20it.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise."><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodexDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"><img
alt="Fix in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"
height="20"></picture></a>
</details>

<!-- /greptile_failed_comments -->

<a
href="https://app.greptile.com/api/ide/cursor?prompt=Fix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Aagentex%2Ftests%2Funit%2Fapi%2Ftest_agent_api_keys_authz.py%3A195-213%0A**Vacuous%20assertion%20on%20removed%20method**%0A%0A%60api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called%28%29%60%20is%20dead%20code%20%E2%80%94%20%60delete_agent_api_key_by_name%60%20no%20longer%20calls%20%60delete_by_agent_id_and_key_name%60%3B%20it%20calls%20%60agent_api_key_use_case.delete%28id%3D...%29%60.%20The%20comment%20says%20%22the%20delete%20is%20NOT%20invoked%20when%20the%20check%20fails%2C%22%20but%20the%20assertion%20doesn't%20test%20that.%20If%20someone%20accidentally%20removed%20the%20%60_check_api_key_or_collapse_to_404%60%20guard%2C%20this%20assertion%20would%20still%20pass.%20Add%20%60api_key_use_case.delete%20%3D%20AsyncMock%28%29%60%20in%20the%20setup%20and%20assert%20%60api_key_use_case.delete.assert_not_called%28%29%60%20to%20test%20the%20intended%20safety%20property.%0A%0A%23%23%23%20Issue%202%20of%202%0Aagentex%2Fsrc%2Futils%2Fagent_api_key_authorization.py%3A13-17%0AMissing%20type%20annotation%20on%20%60authorization%60%20parameter%20%E2%80%94%20the%20other%20parameters%20are%20typed%20but%20this%20one%20isn't%2C%20so%20type-checkers%20won't%20catch%20callers%20passing%20the%20wrong%20type.%20The%20concrete%20type%20is%20%60DAuthorizationService%60%20%28matching%20every%20call%20site%20in%20this%20PR%29.%0A%0A%60%60%60suggestion%0Aasync%20def%20_check_api_key_or_collapse_to_404%28%0A%20%20%20%20authorization%3A%20%22DAuthorizationService%22%2C%0A%20%20%20%20api_key_id%3A%20str%2C%0A%20%20%20%20operation%3A%20AuthorizedOperationType%2C%0A%29%20-%3E%20None%3A%0A%60%60%60%0A%0A&pr=252&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursorDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=3"><img
alt="Fix All in Cursor"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=3"
height="20"></picture></a> <a
href="https://app.greptile.com/ide/claude-code?prompt=Fix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Aagentex%2Ftests%2Funit%2Fapi%2Ftest_agent_api_keys_authz.py%3A195-213%0A**Vacuous%20assertion%20on%20removed%20method**%0A%0A%60api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called%28%29%60%20is%20dead%20code%20%E2%80%94%20%60delete_agent_api_key_by_name%60%20no%20longer%20calls%20%60delete_by_agent_id_and_key_name%60%3B%20it%20calls%20%60agent_api_key_use_case.delete%28id%3D...%29%60.%20The%20comment%20says%20%22the%20delete%20is%20NOT%20invoked%20when%20the%20check%20fails%2C%22%20but%20the%20assertion%20doesn't%20test%20that.%20If%20someone%20accidentally%20removed%20the%20%60_check_api_key_or_collapse_to_404%60%20guard%2C%20this%20assertion%20would%20still%20pass.%20Add%20%60api_key_use_case.delete%20%3D%20AsyncMock%28%29%60%20in%20the%20setup%20and%20assert%20%60api_key_use_case.delete.assert_not_called%28%29%60%20to%20test%20the%20intended%20safety%20property.%0A%0A%23%23%23%20Issue%202%20of%202%0Aagentex%2Fsrc%2Futils%2Fagent_api_key_authorization.py%3A13-17%0AMissing%20type%20annotation%20on%20%60authorization%60%20parameter%20%E2%80%94%20the%20other%20parameters%20are%20typed%20but%20this%20one%20isn't%2C%20so%20type-checkers%20won't%20catch%20callers%20passing%20the%20wrong%20type.%20The%20concrete%20type%20is%20%60DAuthorizationService%60%20%28matching%20every%20call%20site%20in%20this%20PR%29.%0A%0A%60%60%60suggestion%0Aasync%20def%20_check_api_key_or_collapse_to_404%28%0A%20%20%20%20authorization%3A%20%22DAuthorizationService%22%2C%0A%20%20%20%20api_key_id%3A%20str%2C%0A%20%20%20%20operation%3A%20AuthorizedOperationType%2C%0A%29%20-%3E%20None%3A%0A%60%60%60%0A%0A&repo=scaleapi%2Fscale-agentex&pr=252&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaudeDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=3"><img
alt="Fix All in Claude Code"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=3"
height="20"></picture></a> <a
href="https://chatgpt.com/codex/deeplink?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%0A%0AFix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Aagentex%2Ftests%2Funit%2Fapi%2Ftest_agent_api_keys_authz.py%3A195-213%0A**Vacuous%20assertion%20on%20removed%20method**%0A%0A%60api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called%28%29%60%20is%20dead%20code%20%E2%80%94%20%60delete_agent_api_key_by_name%60%20no%20longer%20calls%20%60delete_by_agent_id_and_key_name%60%3B%20it%20calls%20%60agent_api_key_use_case.delete%28id%3D...%29%60.%20The%20comment%20says%20%22the%20delete%20is%20NOT%20invoked%20when%20the%20check%20fails%2C%22%20but%20the%20assertion%20doesn't%20test%20that.%20If%20someone%20accidentally%20removed%20the%20%60_check_api_key_or_collapse_to_404%60%20guard%2C%20this%20assertion%20would%20still%20pass.%20Add%20%60api_key_use_case.delete%20%3D%20AsyncMock%28%29%60%20in%20the%20setup%20and%20assert%20%60api_key_use_case.delete.assert_not_called%28%29%60%20to%20test%20the%20intended%20safety%20property.%0A%0A%23%23%23%20Issue%202%20of%202%0Aagentex%2Fsrc%2Futils%2Fagent_api_key_authorization.py%3A13-17%0AMissing%20type%20annotation%20on%20%60authorization%60%20parameter%20%E2%80%94%20the%20other%20parameters%20are%20typed%20but%20this%20one%20isn't%2C%20so%20type-checkers%20won't%20catch%20callers%20passing%20the%20wrong%20type.%20The%20concrete%20type%20is%20%60DAuthorizationService%60%20%28matching%20every%20call%20site%20in%20this%20PR%29.%0A%0A%60%60%60suggestion%0Aasync%20def%20_check_api_key_or_collapse_to_404%28%0A%20%20%20%20authorization%3A%20%22DAuthorizationService%22%2C%0A%20%20%20%20api_key_id%3A%20str%2C%0A%20%20%20%20operation%3A%20AuthorizedOperationType%2C%0A%29%20-%3E%20None%3A%0A%60%60%60%0A%0A"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=3"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=3"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=3"
height="20"></picture></a>

<details><summary>Prompt To Fix All With AI</summary>

`````markdown
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
agentex/tests/unit/api/test_agent_api_keys_authz.py:195-213
**Vacuous assertion on removed method**

`api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called()` is dead code — `delete_agent_api_key_by_name` no longer calls `delete_by_agent_id_and_key_name`; it calls `agent_api_key_use_case.delete(id=...)`. The comment says "the delete is NOT invoked when the check fails," but the assertion doesn't test that. If someone accidentally removed the `_check_api_key_or_collapse_to_404` guard, this assertion would still pass. Add `api_key_use_case.delete = AsyncMock()` in the setup and assert `api_key_use_case.delete.assert_not_called()` to test the intended safety property.

### Issue 2 of 2
agentex/src/utils/agent_api_key_authorization.py:13-17
Missing type annotation on `authorization` parameter — the other parameters are typed but this one isn't, so type-checkers won't catch callers passing the wrong type. The concrete type is `DAuthorizationService` (matching every call site in this PR).

```suggestion
async def _check_api_key_or_collapse_to_404(
    authorization: "DAuthorizationService",
    api_key_id: str,
    operation: AuthorizedOperationType,
) -> None:
```


`````

</details>

<sub>Reviews (4): Last reviewed commit: ["refactor(AGX1-263): close
delete-by-name..."](29a822a)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=34413167)</sub>

<!-- /greptile_comment -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
asherfink added a commit that referenced this pull request Jun 3, 2026
…strings (#268)

Scrubs internal references (Linear ticket IDs, internal service names,
internal flag names) from comments and docstrings across already-landed
authorization code. Fixes issues introduced by #248, #252, #255, #257,
#259, and #260.

No behavior changes.

<!-- greptile_comment -->

<h3>Greptile Summary</h3>

This PR is a pure housekeeping change that scrubs internal references —
Linear ticket IDs (e.g. `AGX1-263`, `AGX1-290`), internal service names
(`SpiceDB`, `Spark`, `agentex-auth`), and internal flag/PR references —
from comments and docstrings across 13 authorization-related source and
test files. No executable code is touched.

- Comments and docstrings across `port.py`, `authorization_service.py`,
`agent_api_keys.py`, and related utilities have internal service names
replaced with generic terms like \"authorization service\" or
\"authorization schema\".
- TODO comments in `agent_api_key_authorization.py`,
`agent_authorization.py`, and `authorization_shortcuts.py` have internal
ticket IDs stripped, with file-level context substituted where the
ticket reference previously identified the work location.
- Module docstrings in all six affected test files have ticket-prefixed
deliverable descriptions replaced with plain behavioral descriptions.

<details><summary><h3>Confidence Score: 5/5</h3></summary>

All changes are confined to comments and docstrings; no executable code
is modified.

Every diff hunk touches only comment text, docstrings, or module-level
strings. There are no logic, control-flow, or interface changes anywhere
in the PR, making regression risk essentially zero.

No files require special attention.
</details>

<h3>Important Files Changed</h3>

| Filename | Overview |
|----------|----------|
| agentex/src/adapters/authorization/port.py | Docstring-only: replaced
"SpiceDB" with "authorization" in register_resource docstring. |
| agentex/src/api/routes/agent_api_keys.py | Comment-only: removed
"SpiceDB" references from two inline comments in create/delete route
handlers. |
| agentex/src/domain/services/authorization_service.py | Docstring-only:
replaced "SpiceDB schema" with "authorization schema" in
register_resource docstring. |
| agentex/src/domain/use_cases/agent_api_keys_use_case.py |
Comment-only: removed unconditional-routing comment referencing internal
service names and ticket numbers; also cleaned docstring referencing
internal routing logic. |
| agentex/src/utils/agent_api_key_authorization.py | Comment-only:
replaced TODO(AGX1-290) with plain TODO, removing the Linear ticket
reference. |
| agentex/src/utils/agent_authorization.py | Comment-only: replaced
TODO(AGX1-290) with plain TODO, removing the Linear ticket reference. |
| agentex/src/utils/authorization_shortcuts.py | Comment-only: updated
TODO to reference the file name instead of internal ticket/PR numbers. |
|
agentex/tests/integration/services/test_agent_api_key_service_dual_write.py
| Docstring-only: removed references to "Spark AuthZ", internal ticket
numbers, and internal service names throughout module docstring and
inline comments. |
| agentex/tests/integration/services/test_schedule_service_dual_write.py
| Docstring-only: removed "Spark AuthZ" and "SpiceDB" references across
the module docstring and inline comments. |
|
agentex/tests/integration/api/checkpoints/test_checkpoints_authz_api.py
| Docstring-only: removed AGX1-302 ticket reference and internal
terminology from module docstring. |
| agentex/tests/integration/api/events/test_events_authz_api.py |
Docstring-only: replaced "AGX1-244" ticket reference in module docstring
with a plain description. |
| agentex/tests/integration/api/messages/test_messages_authz_api.py |
Docstring-only: removed AGX1-277 ticket reference from module docstring.
|
| agentex/tests/unit/api/test_agent_api_keys_authz.py | Docstring-only:
removed AGX1-263 ticket reference and "Spark AuthZ" mention from module
docstring. |

</details>

<details><summary><h3>Flowchart</h3></summary>

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["PR #268: Scrub internal references"] --> B["Source files\n(port.py, agent_api_keys.py,\nauthorization_service.py,\nagent_api_keys_use_case.py,\n3 utils files)"]
    A --> C["Test files\n(6 integration + unit tests)"]
    B --> D["Replace: SpiceDB → authorization service/schema\nRemove: agentex-auth, Spark, internal PR refs\nStrip: AGX1-xxx ticket IDs from TODOs"]
    C --> E["Replace: AGX1-xxx deliverable labels → plain descriptions\nRemove: Spark AuthZ, scaleapi/agentex#353, etc."]
    D --> F["No executable code changed"]
    E --> F
```
</details>

<sub>Reviews (2): Last reviewed commit: ["chore(authz): scrub internal
project
ref..."](62581f7)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=35250665)</sub>

<!-- /greptile_comment -->
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.

5 participants