Skip to content

Commit 7e4d50e

Browse files
dm36claude
andcommitted
feat(AGX1-272): dual-write agent_api_keys to spark-authz behind FGAC_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>
1 parent e1fb515 commit 7e4d50e

12 files changed

Lines changed: 594 additions & 12 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""add_agent_api_key_creator_and_zedtoken
2+
3+
Revision ID: b2c84edb77d6
4+
Revises: a9959ebcbe98
5+
Create Date: 2026-05-26 12:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'b2c84edb77d6'
16+
down_revision: Union[str, None] = 'a9959ebcbe98'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
op.add_column('agent_api_keys', sa.Column('creator_user_id', sa.String(), nullable=True))
23+
op.add_column('agent_api_keys', sa.Column('creator_service_account_id', sa.String(), nullable=True))
24+
op.add_column('agent_api_keys', sa.Column('spark_authz_zedtoken', sa.Text(), nullable=True))
25+
with op.get_context().autocommit_block():
26+
op.execute(
27+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_agent_api_keys_creator_user_id "
28+
"ON agent_api_keys (creator_user_id)"
29+
)
30+
op.execute(
31+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_agent_api_keys_creator_service_account_id "
32+
"ON agent_api_keys (creator_service_account_id)"
33+
)
34+
op.create_check_constraint(
35+
'ck_agent_api_keys_one_creator',
36+
'agent_api_keys',
37+
'(creator_user_id IS NULL) OR (creator_service_account_id IS NULL)',
38+
)
39+
40+
41+
def downgrade() -> None:
42+
op.drop_constraint('ck_agent_api_keys_one_creator', 'agent_api_keys', type_='check')
43+
with op.get_context().autocommit_block():
44+
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_agent_api_keys_creator_service_account_id")
45+
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_agent_api_keys_creator_user_id")
46+
op.drop_column('agent_api_keys', 'spark_authz_zedtoken')
47+
op.drop_column('agent_api_keys', 'creator_service_account_id')
48+
op.drop_column('agent_api_keys', 'creator_user_id')

agentex/database/migrations/migration_history.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
9ff3ee32c81b -> e9c4ff9e6542 (head), add_tasks_metadata_gin_index
1+
a9959ebcbe98 -> b2c84edb77d6 (head), add_agent_api_key_creator_and_zedtoken
2+
e9c4ff9e6542 -> a9959ebcbe98, finalize_spans_task_id
3+
9ff3ee32c81b -> e9c4ff9e6542, add_tasks_metadata_gin_index
24
57c5ed4f59ae -> 9ff3ee32c81b, uppercase deployment status enum labels
35
4a9b7787ccd7 -> 57c5ed4f59ae, add_task_id_to_spans
46
d1a6cde41b3f -> 4a9b7787ccd7, deployments

agentex/src/adapters/orm.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ class AgentAPIKeyORM(BaseORM):
180180
name = Column(String(256), nullable=False, index=True)
181181
api_key_type = Column(SQLAlchemyEnum(AgentAPIKeyType), nullable=False)
182182
api_key = Column(String, nullable=False)
183+
creator_user_id = Column(String, nullable=True, index=True)
184+
creator_service_account_id = Column(String, nullable=True, index=True)
185+
spark_authz_zedtoken = Column(Text, nullable=True)
183186

184187
# Indexes for efficient querying
185188
__table_args__ = (

agentex/src/api/routes/agent_api_keys.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CreateAPIKeyResponse,
99
)
1010
from src.domain.entities.agent_api_keys import AgentAPIKeyType
11+
from src.domain.services.authorization_service import DAuthorizationService
1112
from src.domain.use_cases.agent_api_keys_use_case import DAgentAPIKeysUseCase
1213
from src.domain.use_cases.agents_use_case import DAgentsUseCase
1314
from src.utils.logging import make_logger
@@ -28,6 +29,7 @@ async def create_api_key(
2829
request: CreateAPIKeyRequest,
2930
agent_api_key_use_case: DAgentAPIKeysUseCase,
3031
agent_use_case: DAgentsUseCase,
32+
authorization_service: DAuthorizationService,
3133
) -> CreateAPIKeyResponse:
3234
if not request.agent_id and not request.agent_name:
3335
raise HTTPException(
@@ -52,11 +54,13 @@ async def create_api_key(
5254
raise HTTPException(status_code=409, detail=error_msg)
5355

5456
new_api_key = request.api_key or secrets.token_hex(32)
57+
account_id = getattr(authorization_service.principal_context, "account_id", None)
5558
agent_api_key_entity = await agent_api_key_use_case.create(
5659
agent_id=agent.id,
5760
api_key=str(new_api_key),
5861
name=request.name,
5962
api_key_type=request.api_key_type,
63+
account_id=account_id,
6064
)
6165
return CreateAPIKeyResponse(
6266
id=agent_api_key_entity.id,
@@ -161,8 +165,10 @@ async def get_agent_api_key(
161165
async def delete_agent_api_key(
162166
id: str,
163167
agent_api_key_use_case: DAgentAPIKeysUseCase,
168+
authorization_service: DAuthorizationService,
164169
) -> str:
165-
await agent_api_key_use_case.delete(id=id)
170+
account_id = getattr(authorization_service.principal_context, "account_id", None)
171+
await agent_api_key_use_case.delete(id=id, account_id=account_id)
166172
return f"Agent API key with ID {id} deleted"
167173

168174

@@ -176,6 +182,7 @@ async def delete_agent_api_key_by_name(
176182
api_key_name: str,
177183
agent_api_key_use_case: DAgentAPIKeysUseCase,
178184
agent_use_case: DAgentsUseCase,
185+
authorization_service: DAuthorizationService,
179186
agent_id: str | None = None,
180187
agent_name: str | None = None,
181188
api_key_type: AgentAPIKeyType = AgentAPIKeyType.EXTERNAL,
@@ -191,8 +198,12 @@ async def delete_agent_api_key_by_name(
191198
detail="Only one of 'agent_id' or 'agent_name' should be provided to delete an agent api_key.",
192199
)
193200
agent = await agent_use_case.get(id=agent_id, name=agent_name)
201+
account_id = getattr(authorization_service.principal_context, "account_id", None)
194202
await agent_api_key_use_case.delete_by_agent_id_and_key_name(
195-
agent_id=agent.id, key_name=api_key_name, api_key_type=api_key_type
203+
agent_id=agent.id,
204+
key_name=api_key_name,
205+
api_key_type=api_key_type,
206+
account_id=account_id,
196207
)
197208

198209
return f"Agent api_key '{api_key_name}' deleted"

agentex/src/api/schemas/authorization_types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class AuthorizedOperationType(StrEnum):
1414
class AgentexResourceType(StrEnum):
1515
agent = "agent"
1616
task = "task"
17+
api_key = "api_key"
1718

1819

1920
# Resources that inherit permissions from their parent task
@@ -37,6 +38,10 @@ def agent(cls, selector: str) -> "AgentexResource":
3738
def task(cls, selector: str) -> "AgentexResource":
3839
return cls(type=AgentexResourceType.task, selector=selector)
3940

41+
@classmethod
42+
def api_key(cls, selector: str) -> "AgentexResource":
43+
return cls(type=AgentexResourceType.api_key, selector=selector)
44+
4045

4146
class AgentexResourceOptionalSelector(BaseModel):
4247
type: AgentexResourceType
@@ -49,3 +54,7 @@ def agent(cls, selector: str | None = None) -> "AgentexResourceOptionalSelector"
4954
@classmethod
5055
def task(cls, selector: str | None = None) -> "AgentexResourceOptionalSelector":
5156
return cls(type=AgentexResourceType.task, selector=selector)
57+
58+
@classmethod
59+
def api_key(cls, selector: str | None = None) -> "AgentexResourceOptionalSelector":
60+
return cls(type=AgentexResourceType.api_key, selector=selector)

agentex/src/domain/entities/agent_api_keys.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,15 @@ class AgentAPIKeyEntity(BaseModel):
2525
description="The type of the API key (either internal or external)",
2626
)
2727
api_key: str = Field(..., description="The API key")
28+
creator_user_id: str | None = Field(
29+
None,
30+
description="Identity ID of the user who created this API key (granted as FGAC owner)",
31+
)
32+
creator_service_account_id: str | None = Field(
33+
None,
34+
description="Service identity ID of the service account that created this API key",
35+
)
36+
spark_authz_zedtoken: str | None = Field(
37+
None,
38+
description="ZedToken from the Spark AuthZ grant for new-write isolation",
39+
)

agentex/src/domain/use_cases/agent_api_keys_use_case.py

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from src.adapters.crud_store.exceptions import ItemDoesNotExist
1515
from src.api.middleware_utils import get_request_headers_to_forward, verify_auth_gateway
16+
from src.api.schemas.authorization_types import AgentexResource
1617
from src.config.dependencies import (
1718
DHttpxClient,
1819
resolve_environment_variable_dependency,
@@ -27,6 +28,8 @@
2728
DAgentAPIKeyRepository,
2829
)
2930
from src.domain.repositories.agent_repository import DAgentRepository
31+
from src.domain.services.authorization_service import DAuthorizationService
32+
from src.utils.feature_flags import DFeatureFlagProvider, FeatureFlagName
3033
from src.utils.ids import orm_id
3134
from src.utils.logging import make_logger
3235

@@ -39,10 +42,14 @@ def __init__(
3942
agent_api_key_repository: DAgentAPIKeyRepository,
4043
agent_repository: DAgentRepository,
4144
client: DHttpxClient,
45+
authorization_service: DAuthorizationService,
46+
feature_flags: DFeatureFlagProvider,
4247
):
4348
self.agent_api_key_repo = agent_api_key_repository
4449
self.agent_repo = agent_repository
4550
self.client = client
51+
self.authorization_service = authorization_service
52+
self.feature_flags = feature_flags
4653
self.auth_gateway_enabled = bool(
4754
resolve_environment_variable_dependency(EnvVarKeys.AGENTEX_AUTH_URL)
4855
)
@@ -76,24 +83,115 @@ async def create(
7683
agent_id: str,
7784
api_key_type: AgentAPIKeyType,
7885
api_key: str,
86+
account_id: str | None = None,
7987
) -> AgentAPIKeyEntity:
8088
agent = await self.get_agent(agent_id=agent_id)
8189
if not agent:
8290
raise HTTPException(
8391
status_code=404,
8492
detail=f"Agent ID {agent_id} not found.",
8593
)
94+
95+
principal_context = self.authorization_service.principal_context
96+
creator_user_id = getattr(principal_context, "user_id", None)
97+
creator_service_account_id = getattr(
98+
principal_context, "service_account_id", None
99+
)
100+
101+
api_key_id = orm_id()
102+
zedtoken: str | None = None
103+
104+
if self.feature_flags.is_enabled(
105+
FeatureFlagName.FGAC_AGENT_API_KEYS_DUAL_WRITE, account_id
106+
):
107+
zedtoken = await self._register_api_key_in_spark_authz(
108+
api_key_id=api_key_id,
109+
agent_id=agent.id,
110+
account_id=account_id,
111+
creator_user_id=creator_user_id,
112+
creator_service_account_id=creator_service_account_id,
113+
)
114+
86115
# TODO: encrypt API key before storing it
87116
# Initialize a new agent api_key
88117
agent_api_key = AgentAPIKeyEntity(
89-
id=orm_id(),
118+
id=api_key_id,
90119
name=name,
91120
agent_id=agent.id,
92121
api_key_type=api_key_type,
93122
api_key=api_key,
123+
creator_user_id=creator_user_id,
124+
creator_service_account_id=creator_service_account_id,
125+
spark_authz_zedtoken=zedtoken,
94126
)
95127
return await self.agent_api_key_repo.create(item=agent_api_key)
96128

129+
async def _register_api_key_in_spark_authz(
130+
self,
131+
*,
132+
api_key_id: str,
133+
agent_id: str,
134+
account_id: str | None,
135+
creator_user_id: str | None,
136+
creator_service_account_id: str | None,
137+
) -> str | None:
138+
"""Register a new agent_api_key in Spark AuthZ with creator as owner.
139+
140+
Called BEFORE the Postgres write — a failure raises and prevents the
141+
row from being persisted, so there is no compensating action to take.
142+
Mirrors the dual-write pattern used for tasks (AGX1-274).
143+
144+
The current ``Provider.spark`` adapter returns ``{}`` from ``grant``;
145+
no ZedToken is surfaced today, so we always return ``None`` for the
146+
new-write-isolation column. A follow-up will plumb the token through
147+
once the adapter exposes it.
148+
149+
Note: the ``agent_api_key`` SpiceDB schema has a ``parent_agent``
150+
relation that read/delete permissions cascade through. The current
151+
``AuthorizationGateway.grant`` signature does not accept a parent
152+
relation — the agentex-auth adapter is expected to set
153+
``parent_agent`` based on the resource shape. This is the same
154+
gap Asher's task PR has and is tracked as a follow-up.
155+
"""
156+
if creator_user_id is None and creator_service_account_id is None:
157+
logger.warning(
158+
"Skipping Spark AuthZ api_key registration: no creator resolvable",
159+
extra={
160+
"api_key_id": api_key_id,
161+
"agent_id": agent_id,
162+
"account_id": account_id,
163+
},
164+
)
165+
return None
166+
await self.authorization_service.grant(
167+
resource=AgentexResource.api_key(api_key_id),
168+
)
169+
return None
170+
171+
async def _deregister_api_key_from_spark_authz(
172+
self, *, api_key_id: str, account_id: str | None
173+
) -> None:
174+
"""Best-effort revocation of an api_key's Spark AuthZ tuples on delete.
175+
176+
Only invoked when the FGAC_AGENT_API_KEYS_DUAL_WRITE flag is enabled
177+
for the caller's account. Failures are logged but do not block the
178+
delete.
179+
"""
180+
if not self.feature_flags.is_enabled(
181+
FeatureFlagName.FGAC_AGENT_API_KEYS_DUAL_WRITE, account_id
182+
):
183+
return
184+
try:
185+
await self.authorization_service.revoke(
186+
resource=AgentexResource.api_key(api_key_id),
187+
)
188+
except Exception:
189+
logger.warning(
190+
"Spark AuthZ revoke failed for agent_api_key",
191+
extra={"api_key_id": api_key_id, "account_id": account_id},
192+
exc_info=True,
193+
)
194+
97195
async def get(self, id: str) -> AgentAPIKeyEntity:
98196
return await self.agent_api_key_repo.get(id=id)
99197

@@ -123,22 +221,47 @@ async def get_external_by_agent_id_and_key(
123221
agent_id=agent_id, api_key=api_key
124222
)
125223

126-
async def delete(self, id: str) -> None:
127-
return await self.agent_api_key_repo.delete(id=id)
224+
async def delete(self, id: str, account_id: str | None = None) -> None:
225+
await self.agent_api_key_repo.delete(id=id)
226+
await self._deregister_api_key_from_spark_authz(
227+
api_key_id=id, account_id=account_id
228+
)
128229

129230
async def delete_by_agent_id_and_key_name(
130-
self, agent_id: str, key_name: str, api_key_type: AgentAPIKeyType
231+
self,
232+
agent_id: str,
233+
key_name: str,
234+
api_key_type: AgentAPIKeyType,
235+
account_id: str | None = None,
131236
) -> None:
132-
return await self.agent_api_key_repo.delete_by_agent_id_and_key_name(
237+
existing = await self.agent_api_key_repo.get_by_agent_id_and_name(
238+
agent_id=agent_id, name=key_name, api_key_type=api_key_type
239+
)
240+
await self.agent_api_key_repo.delete_by_agent_id_and_key_name(
133241
agent_id=agent_id, key_name=key_name, api_key_type=api_key_type
134242
)
243+
if existing is not None:
244+
await self._deregister_api_key_from_spark_authz(
245+
api_key_id=existing.id, account_id=account_id
246+
)
135247

136248
async def delete_by_agent_name_and_key_name(
137-
self, agent_name: str, key_name: str, api_key_type: AgentAPIKeyType
249+
self,
250+
agent_name: str,
251+
key_name: str,
252+
api_key_type: AgentAPIKeyType,
253+
account_id: str | None = None,
138254
) -> None:
139-
return await self.agent_api_key_repo.delete_by_agent_name_and_key_name(
255+
existing = await self.agent_api_key_repo.get_by_agent_name_and_key_name(
256+
agent_name, key_name, api_key_type
257+
)
258+
await self.agent_api_key_repo.delete_by_agent_name_and_key_name(
140259
agent_name=agent_name, key_name=key_name, api_key_type=api_key_type
141260
)
261+
if existing is not None:
262+
await self._deregister_api_key_from_spark_authz(
263+
api_key_id=existing.id, account_id=account_id
264+
)
142265

143266
async def list(
144267
self, agent_id: str, limit: int, page_number: int

0 commit comments

Comments
 (0)