Skip to content

Commit d15bc88

Browse files
dm36claude
andcommitted
feat(AGX1-263): migrate agent_api_keys routes to FGAC with 404 collapse 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>
1 parent 9668f1a commit d15bc88

4 files changed

Lines changed: 504 additions & 0 deletions

File tree

agentex/src/api/routes/agent_api_keys.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,20 @@
77
CreateAPIKeyRequest,
88
CreateAPIKeyResponse,
99
)
10+
from src.api.schemas.authorization_types import (
11+
AgentexResource,
12+
AgentexResourceType,
13+
AuthorizedOperationType,
14+
)
1015
from src.domain.entities.agent_api_keys import AgentAPIKeyType
1116
from src.domain.services.authorization_service import DAuthorizationService
1217
from src.domain.use_cases.agent_api_keys_use_case import DAgentAPIKeysUseCase
1318
from src.domain.use_cases.agents_use_case import DAgentsUseCase
19+
from src.utils.agent_api_key_authorization import _check_api_key_or_collapse_to_404
20+
from src.utils.authorization_shortcuts import (
21+
DAuthorizedId,
22+
DAuthorizedResourceIds,
23+
)
1424
from src.utils.logging import make_logger
1525

1626
logger = make_logger(__name__)
@@ -42,6 +52,16 @@ async def create_api_key(
4252
detail="Only one of 'agent_id' or 'agent_name' should be provided to create an agent api_key.",
4353
)
4454
agent = await agent_use_case.get(id=request.agent_id, name=request.agent_name)
55+
56+
# Parent-agent FGAC: only callers with ``update`` on the parent agent may
57+
# mint a new api_key under it. The new api_key has no resource row yet, so
58+
# this is the only enforcement surface at create time. SpiceDB cannot
59+
# transitively gate on a not-yet-created child.
60+
await authorization_service.check(
61+
resource=AgentexResource.agent(agent.id),
62+
operation=AuthorizedOperationType.update,
63+
)
64+
4565
# Check if external agent API key already exists for this name and agent ID
4666
existing_api_key = await agent_api_key_use_case.get_by_agent_id_and_name(
4767
agent_id=agent.id,
@@ -81,6 +101,9 @@ async def create_api_key(
81101
async def list_agent_api_keys(
82102
agent_api_key_use_case: DAgentAPIKeysUseCase,
83103
agent_use_case: DAgentsUseCase,
104+
authorized_api_key_ids: DAuthorizedResourceIds(
105+
AgentexResourceType.api_key, AuthorizedOperationType.read
106+
),
84107
agent_id: str | None = None,
85108
agent_name: str | None = None,
86109
limit: int = Query(default=50, ge=1, le=1000),
@@ -100,6 +123,11 @@ async def list_agent_api_keys(
100123
agent_api_key_entities = await agent_api_key_use_case.list(
101124
agent_id=agent.id, limit=limit, page_number=page_number
102125
)
126+
# Filter to the set of api_keys the caller can read. ``None`` means the
127+
# authz backend declined to enumerate (e.g. bypass) — pass through.
128+
if authorized_api_key_ids is not None:
129+
allowed = set(authorized_api_key_ids)
130+
agent_api_key_entities = [e for e in agent_api_key_entities if e.id in allowed]
103131
return [
104132
AgentAPIKey.model_validate(agent_api_key_entity)
105133
for agent_api_key_entity in agent_api_key_entities
@@ -116,6 +144,7 @@ async def get_agent_api_key_by_name(
116144
name: str,
117145
agent_api_key_use_case: DAgentAPIKeysUseCase,
118146
agent_use_case: DAgentsUseCase,
147+
authorization_service: DAuthorizationService,
119148
agent_id: str | None = None,
120149
agent_name: str | None = None,
121150
api_key_type: AgentAPIKeyType = AgentAPIKeyType.EXTERNAL,
@@ -139,6 +168,15 @@ async def get_agent_api_key_by_name(
139168
status_code=404,
140169
detail=f"Agent api_key '{name}' not found for agent ID {agent.id}",
141170
)
171+
# Name routes for api_key don't fit ``DAuthorizedName`` (the lookup key is
172+
# ``(agent_id, name, api_key_type)``, not a single globally-unique name
173+
# path param). Apply the collapse helper explicitly so present-but-denied
174+
# surfaces as 404, mirroring tasks' name route in PR #249.
175+
await _check_api_key_or_collapse_to_404(
176+
authorization_service,
177+
agent_api_key_entity.id,
178+
AuthorizedOperationType.read,
179+
)
142180
return AgentAPIKey.model_validate(agent_api_key_entity)
143181

144182

@@ -151,6 +189,11 @@ async def get_agent_api_key_by_name(
151189
async def get_agent_api_key(
152190
id: str,
153191
agent_api_key_use_case: DAgentAPIKeysUseCase,
192+
_authorized_id: DAuthorizedId(
193+
AgentexResourceType.api_key,
194+
AuthorizedOperationType.read,
195+
param_name="id",
196+
),
154197
) -> AgentAPIKey:
155198
agent_api_key_entity = await agent_api_key_use_case.get(id=id)
156199
return AgentAPIKey.model_validate(agent_api_key_entity)
@@ -166,7 +209,18 @@ async def delete_agent_api_key(
166209
id: str,
167210
agent_api_key_use_case: DAgentAPIKeysUseCase,
168211
authorization_service: DAuthorizationService,
212+
_authorized_id: DAuthorizedId(
213+
AgentexResourceType.api_key,
214+
AuthorizedOperationType.delete,
215+
param_name="id",
216+
),
169217
) -> str:
218+
# Two-factor mutation: SpiceDB's ``api_key.delete`` permission depends
219+
# transitively on ``parent_agent->update`` per the schema, so the
220+
# ``DAuthorizedId(..., delete)`` dep above enforces both api_key.delete
221+
# and the parent-agent.update factor. No explicit second
222+
# ``authorization_service.check`` on the parent agent is needed (matches
223+
# Asher's PR #249 approach for tasks).
170224
account_id = getattr(authorization_service.principal_context, "account_id", None)
171225
await agent_api_key_use_case.delete(id=id, account_id=account_id)
172226
return f"Agent API key with ID {id} deleted"
@@ -198,6 +252,25 @@ async def delete_agent_api_key_by_name(
198252
detail="Only one of 'agent_id' or 'agent_name' should be provided to delete an agent api_key.",
199253
)
200254
agent = await agent_use_case.get(id=agent_id, name=agent_name)
255+
256+
# Resolve name -> id, then run the collapse-wrapped delete check before
257+
# mutating. Two-factor (api_key.delete & parent_agent->update) is
258+
# transitively enforced by the SpiceDB schema definition of
259+
# ``api_key.delete``; we don't repeat a parent check here.
260+
existing = await agent_api_key_use_case.get_by_agent_id_and_name(
261+
agent_id=agent.id, name=api_key_name, api_key_type=api_key_type
262+
)
263+
if not existing:
264+
raise HTTPException(
265+
status_code=404,
266+
detail=f"Agent api_key '{api_key_name}' not found for agent ID {agent.id}",
267+
)
268+
await _check_api_key_or_collapse_to_404(
269+
authorization_service,
270+
existing.id,
271+
AuthorizedOperationType.delete,
272+
)
273+
201274
account_id = getattr(authorization_service.principal_context, "account_id", None)
202275
await agent_api_key_use_case.delete_by_agent_id_and_key_name(
203276
agent_id=agent.id,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from src.adapters.authorization.exceptions import AuthorizationError
2+
from src.adapters.crud_store.exceptions import ItemDoesNotExist
3+
from src.api.schemas.authorization_types import (
4+
AgentexResource,
5+
AuthorizedOperationType,
6+
)
7+
8+
9+
async def _check_api_key_or_collapse_to_404(
10+
authorization,
11+
api_key_id: str,
12+
operation: AuthorizedOperationType,
13+
) -> None:
14+
"""Issue a check on an api_key resource. On any denial, surface 404 — even
15+
when the api_key exists.
16+
17+
Same rationale as :func:`src.utils.task_authorization._check_task_or_collapse_to_404`:
18+
the deny-reason discriminator needed to safely return 403 (in-tenant but
19+
lacking the operation) vs 404 (cross-tenant or absent) is not available
20+
here. ``api_key.name`` is unique only per (agent_id, name, api_key_type) —
21+
not globally — but the existence-leak risk via the name routes is still
22+
real: a caller probing ``GET /agent_api_keys/name/{name}?agent_id=...``
23+
against another tenant's agent would otherwise be able to distinguish
24+
"agent has a key with that name (403)" from "no such key (404)".
25+
26+
Until api_keys carry tenant scope at the data layer (or agentex-auth's
27+
deny distinguishes "cross-tenant" from "in-tenant lacking permission"),
28+
the safer default is to collapse both into 404. Trade-off: a user with
29+
``read`` but not ``delete`` permission on an in-tenant api_key sees 404
30+
on delete attempts instead of 403. UX regression for in-tenant permission
31+
granularity, but eliminates the cross-tenant existence leak.
32+
33+
TODO(AGX1-290): Restore the 403/404 split for same-tenant calls once
34+
api_keys carry tenant/account_id scope at the data layer.
35+
"""
36+
try:
37+
await authorization.check(
38+
resource=AgentexResource.api_key(api_key_id),
39+
operation=operation,
40+
)
41+
except AuthorizationError:
42+
raise ItemDoesNotExist(f"Item with id '{api_key_id}' does not exist.") from None

agentex/src/utils/authorization_shortcuts.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from src.domain.repositories.task_repository import DTaskRepository
1414
from src.domain.repositories.task_state_repository import DTaskStateRepository
1515
from src.domain.services.authorization_service import DAuthorizationService
16+
from src.utils.agent_api_key_authorization import _check_api_key_or_collapse_to_404
1617

1718

1819
async def _get_parent_task_id(
@@ -55,6 +56,12 @@ async def _ensure_authorized_id(
5556
resource=AgentexResource.task(task_id),
5657
operation=operation,
5758
)
59+
elif resource_type == AgentexResourceType.api_key:
60+
# Collapse api_key denials to 404 so name/id probes can't
61+
# distinguish "present in another tenant" from "absent".
62+
await _check_api_key_or_collapse_to_404(
63+
authorization, resource_id, operation
64+
)
5865
else:
5966
# For direct resources, check directly
6067
await authorization.check(

0 commit comments

Comments
 (0)