Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ async def list_resources(
)
return response["items"]

async def register_resource(
self,
principal: AgentexAuthPrincipalContext,
resource: AgentexResource,
parent: AgentexResource | None = None,
) -> None:
payload = {
"principal": principal,
"resource": resource.model_dump(),
"parent": parent.model_dump() if parent is not None else None,
}
await HttpRequestHandler.post_with_error_handling(
self.agentex_auth_url, "/v1/authz/register", json=payload
)

async def deregister_resource(
self,
principal: AgentexAuthPrincipalContext,
resource: AgentexResource,
) -> None:
payload = {
"principal": principal,
"resource": resource.model_dump(),
}
await HttpRequestHandler.post_with_error_handling(
self.agentex_auth_url, "/v1/authz/deregister", json=payload
)


DAgentexAuthorization = Annotated[
AgentexAuthorizationProxy, Depends(AgentexAuthorizationProxy)
Expand Down
26 changes: 26 additions & 0 deletions agentex/src/adapters/authorization/port.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,29 @@ async def list_resources(
filter_operation: AuthorizedOperationType = AuthorizedOperationType.read,
) -> Iterable[str]:
"""List resource_ids for a given principal"""

@abstractmethod
async def register_resource(
self,
principal: PrincipalT,
resource: AgentexResource,
parent: AgentexResource | None = None,
) -> None:
"""Register a newly created resource in SpiceDB with the principal as
owner. Optionally writes a lifecycle parent edge.

Use this on resource create instead of ``grant`` when the resource
type's SpiceDB definition has a parent relation that permission
checks cascade through (e.g. ``agent_api_key`` declares
``parent_agent``). Without writing that edge here the cascade fails
closed.
"""

@abstractmethod
async def deregister_resource(
self,
principal: PrincipalT,
resource: AgentexResource,
) -> None:
"""Deregister a deleted resource and all of its relationships
(owner, parent, grantees) in a single atomic call."""
57 changes: 57 additions & 0 deletions agentex/src/domain/services/authorization_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,62 @@ async def list_resources(
)
return result

async def register_resource(
self,
resource: AgentexResource,
parent: AgentexResource | None = None,
*,
principal_context=...,
) -> None:
"""Register a newly created resource with the principal as owner.

Prefer this over ``grant`` when the resource's SpiceDB schema has
a parent relation that permissions cascade through (e.g.
``agent_api_key`` declares ``parent_agent``). Pass ``parent`` to
link the child to its parent atomically; without it the cascade
fails closed.
"""
if self._bypass():
logger.info(f"Authorization bypassed for register_resource on {resource}")
return None

effective_principal = (
principal_context
if principal_context is not ...
else self.principal_context
)
logger.info(
"[authorization_service] Registering %s:%s for principal %s (parent=%s)",
resource.type,
resource.selector,
effective_principal,
f"{parent.type}:{parent.selector}" if parent is not None else None,
)
await self.gateway.register_resource(effective_principal, resource, parent)

async def deregister_resource(
self,
resource: AgentexResource,
*,
principal_context=...,
) -> None:
"""Deregister a deleted resource and all of its relationships."""
if self._bypass():
logger.info(f"Authorization bypassed for deregister_resource on {resource}")
return None

effective_principal = (
principal_context
if principal_context is not ...
else self.principal_context
)
logger.info(
"[authorization_service] Deregistering %s:%s for principal %s",
resource.type,
resource.selector,
effective_principal,
)
await self.gateway.deregister_resource(effective_principal, resource)


DAuthorizationService = Annotated[AuthorizationService, Depends(AuthorizationService)]
70 changes: 48 additions & 22 deletions agentex/src/domain/use_cases/agent_api_keys_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,24 @@ async def _register_api_key_in_spark_authz(
creator_user_id: str | None,
creator_service_account_id: str | None,
) -> str | None:
"""Register a new agent_api_key in Spark AuthZ with creator as owner.
"""Register a new agent_api_key in Spark AuthZ with creator as owner
AND the parent_agent edge to the owning agent.

Called BEFORE the Postgres write — a failure raises and prevents the
row from being persisted, so there is no compensating action to take.
Mirrors the dual-write pattern used for tasks (AGX1-274).

The current ``Provider.spark`` adapter returns ``{}`` from ``grant``;
no ZedToken is surfaced today, so we always return ``None`` for the
new-write-isolation column. A follow-up will plumb the token through
row from being persisted, so there is no compensating action.

The ``agent_api_key`` SpiceDB schema has a ``parent_agent`` relation
that read/update/delete permissions cascade through:
``permission read = internal_effective_viewer & parent_agent->read &
internal_tenant_gate``. We MUST write the parent edge here or every
downstream permission check fails closed. ``register_resource``
(added in agentex-auth and sgp-authz 0.7.0) writes both the owner
edge and the parent edge atomically in one round-trip.

The current ``register_resource`` returns ``None``; no ZedToken is
surfaced today, so we always return ``None`` for the
spark_authz_zedtoken column. A follow-up will plumb the token through
once the adapter exposes it.

Note: the ``agent_api_key`` SpiceDB schema has a ``parent_agent``
relation that read/delete permissions cascade through. The current
``AuthorizationGateway.grant`` signature does not accept a parent
relation — the agentex-auth adapter is expected to set
``parent_agent`` based on the resource shape. This is the same
gap Asher's task PR has and is tracked as a follow-up.
"""
if creator_user_id is None and creator_service_account_id is None:
logger.warning(
Expand All @@ -163,16 +164,36 @@ async def _register_api_key_in_spark_authz(
},
)
return None
await self.authorization_service.grant(
resource=AgentexResource.api_key(api_key_id),
)
try:
await self.authorization_service.register_resource(
resource=AgentexResource.api_key(api_key_id),
parent=AgentexResource.agent(agent_id),
)
except Exception as exc:
# Fail closed: log + re-raise so the Postgres row is never written.
# The dual-write contract requires the SpiceDB tuple (and parent
# edge) to exist before the row does.
logger.exception(
"Spark AuthZ register_resource failed for agent_api_key; aborting create",
extra={
"api_key_id": api_key_id,
"agent_id": agent_id,
"account_id": account_id,
"creator_user_id": creator_user_id,
"creator_service_account_id": creator_service_account_id,
"error_type": type(exc).__name__,
},
)
raise
return None

async def _deregister_api_key_from_spark_authz(
self, *, api_key_id: str, account_id: str | None
) -> None:
"""Best-effort revocation of an api_key's Spark AuthZ tuples on delete.
"""Best-effort deregistration of an api_key's Spark AuthZ tuples on delete.

``deregister_resource`` removes the resource and all of its
relationships (owner, parent_agent, any grantees) atomically.
Only invoked when the FGAC_AGENT_API_KEYS_DUAL_WRITE flag is enabled
for the caller's account. Failures are logged but do not block the
delete.
Expand All @@ -182,13 +203,18 @@ async def _deregister_api_key_from_spark_authz(
):
return
try:
await self.authorization_service.revoke(
await self.authorization_service.deregister_resource(
resource=AgentexResource.api_key(api_key_id),
)
except Exception:
except Exception as exc:
# Best-effort: log and continue. Postgres row already deleted.
logger.warning(
"Spark AuthZ revoke failed for agent_api_key",
extra={"api_key_id": api_key_id, "account_id": account_id},
"Spark AuthZ deregister failed for agent_api_key; tuple may be orphaned",
extra={
"api_key_id": api_key_id,
"account_id": account_id,
"error_type": type(exc).__name__,
},
exc_info=True,
)

Expand Down
Loading
Loading