fastapi-gsap/gsap_broker/delegations/lifecycle.py
Tyler J King e744336385 fix: capability enforcement, credential safety, atomic delegations, input validation
C-6: ConnectorRuntime enforces capability_mask per operation.
     READ-only ACs cannot invoke MUTATE operations (wipe, lock, retire).
C-7: AC validated against database (exists, active, not expired)
     before connector invocation.
C-9: Delegated AC capability bounded by delegator's capability.
C-10: Command counter uses atomic SQL increment with limit check.
M-23: expire_stale() uses same atomic SQL pattern.

H-1: Sensitive credential fields hidden from repr/logs via repr=False.
H-2: Stub backend requires ALLOW_STUB_CREDENTIALS=true to activate.
H-3: Kerberos backend raises CredentialResolutionError instead of
     returning stub ticket.
H-4: Chronicle INTENT emitted before execution, RESULT after.
H-5: device_id validated as UUID before Graph API URL interpolation.
H-8: ConnectorRuntime enforces governance for all connector invocations.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 08:13:27 -04:00

250 lines
9 KiB
Python

"""Delegation lifecycle — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §3.
Originally a separate llm-principal-broker service that called GSAP's
``/governance/authorize/`` endpoint over HTTP. Now an in-process module:
``create_delegation`` opens an AsyncSession against GSAP's own DB engine
and inserts an ``AuthorizationContextDB`` row directly. No process boundary,
no HTTP hop, same Chronicle event emission.
"""
import logging
import uuid
from datetime import datetime, timedelta, UTC
from sqlmodel.ext.asyncio.session import AsyncSession
from gsap_broker import chronicle
from gsap_broker.db import engine
from gsap_broker.db_models import AuthorizationContextDB
from gsap_broker.settings import settings as gsap_settings
from .models import (
AgentPrincipal,
DelegationRequest,
DelegationResponse,
DelegationScope,
)
from .registrars.factory import create_registrar
from .storage import (
DelegationDB,
create_delegation as db_create,
expire_stale,
get_delegation as db_get,
revoke_delegation as db_revoke,
)
logger = logging.getLogger(__name__)
class DelegationManager:
"""Owns the delegation lifecycle. Constructed once at app startup."""
def __init__(self, config=gsap_settings):
self.config = config
self.registrar = create_registrar(config)
async def create_delegation(
self, request: DelegationRequest, delegator_did: str,
delegator_capability_mask: int = 0x7,
) -> DelegationResponse:
delegation_id = f"del-{uuid.uuid4().hex[:8]}"
scope = request.scope or DelegationScope()
# Fix C-9: delegated capability cannot exceed delegator's
requested_mask = _capability_mask_for(scope.capability_ceiling)
if requested_mask & ~delegator_capability_mask:
raise ValueError(
f"Delegated capability ({scope.capability_ceiling} = {requested_mask}) "
f"exceeds delegator's capability ({delegator_capability_mask})"
)
now = datetime.now(UTC)
expires_at = now + timedelta(minutes=scope.max_ttl_minutes)
agent_did = f"did:web:guildhouse.dev/agent/{request.agent_type}-{delegation_id}"
delegator_short = delegator_did.rsplit("/", 1)[-1]
agent_display = f"{request.agent_type} (delegated by {delegator_short})"
# 1. Register agent identity via the configured registrar.
credentials = await self.registrar.register_agent(
delegation_id=delegation_id,
agent_type=request.agent_type,
delegator_id=delegator_did,
display_name=agent_display,
expires_at=expires_at.isoformat(),
metadata={"model": request.agent_model},
)
# 2. Issue a delegated AuthorizationContext directly against the
# GSAP DB. Mirrors routers/authorize.py for the on_behalf_of
# trusted-caller path. We bypass the HTTP layer because GSAP
# is calling itself.
try:
ac_result = await self._issue_delegated_ac(
delegation_id=delegation_id,
agent_did=agent_did,
accord_template=request.accord_template,
expires_at=expires_at,
scope=scope,
)
except Exception as e:
await self.registrar.delete_agent(credentials.client_id)
raise RuntimeError(f"Failed to issue delegated AC: {e}")
# 3. Chronicle event for the delegation creation.
chronicle_cid = await chronicle.emit(
"DELEGATION_CREATED",
{
"event_code": "0x3001",
"delegation_id": delegation_id,
"delegator_did": delegator_did,
"agent_did": agent_did,
"agent_type": request.agent_type,
"scope": scope.model_dump(),
"timestamp": now.isoformat(),
},
)
# 4. Persist the delegation row.
delegation = DelegationDB(
delegation_id=delegation_id,
status="active",
agent_type=request.agent_type,
agent_model=request.agent_model,
agent_did=agent_did,
agent_keycloak_client_id=credentials.client_id,
delegator_did=delegator_did,
delegator_ac_id=request.delegator_ac_id,
delegated_ac_id=ac_result.get("context_id", ""),
capability_ceiling=scope.capability_ceiling,
ceremony_required_for=",".join(scope.ceremony_required_for),
max_commands=scope.max_commands,
created_at=now.replace(tzinfo=None),
expires_at=expires_at.replace(tzinfo=None),
chronicle_cid=chronicle_cid or None,
)
await db_create(delegation)
logger.info(
"Delegation created: %s (%s -> %s) via %s",
delegation_id,
delegator_did,
agent_did,
credentials.idp_backend,
)
return DelegationResponse(
delegation_id=delegation_id,
agent_principal=AgentPrincipal(
did=agent_did,
keycloak_client_id=credentials.client_id,
display_name=credentials.agent_display_name,
),
delegated_ac=ac_result,
agent_token=credentials.client_secret,
expires_at=expires_at.isoformat(),
max_commands=scope.max_commands,
chronicle_cid=chronicle_cid,
)
async def _issue_delegated_ac(
self,
delegation_id: str,
agent_did: str,
accord_template: str,
expires_at: datetime,
scope: DelegationScope,
) -> dict:
"""Insert an AuthorizationContextDB row representing the delegated AC.
Mirrors the trusted-caller (on_behalf_of) path of
``routers/authorize.authorize`` without going through HTTP.
"""
ctx_id = uuid.uuid4()
now = datetime.now(UTC)
ac_db = AuthorizationContextDB(
context_id=ctx_id,
principal_did=agent_did,
driver_id="delegation",
playbook=f"delegation:{delegation_id}",
corpus_entry_cid="sha256:delegated-agent",
parameters_cid=f"sha256:delegation-{delegation_id}",
accord_template=accord_template,
capability_mask=_capability_mask_for(scope.capability_ceiling),
idp_vendor="delegation",
token_jti="",
elevation_active=[],
mfa_satisfied=False,
status="authorized",
issued_at=now.replace(tzinfo=None),
expires_at=expires_at.replace(tzinfo=None),
session_mode=True,
)
async with AsyncSession(engine) as session:
session.add(ac_db)
await session.commit()
return {
"status": "authorized",
"context_id": str(ctx_id),
"principal_did": agent_did,
"delegation_id": delegation_id,
"capability_ceiling": scope.capability_ceiling,
"expires_at": expires_at.isoformat(),
}
async def revoke_delegation(self, delegation_id: str, reason: str) -> bool:
delegation = await db_get(delegation_id)
if not delegation or delegation.status != "active":
return False
if delegation.agent_keycloak_client_id:
await self.registrar.delete_agent(delegation.agent_keycloak_client_id)
await db_revoke(delegation_id, reason)
await chronicle.emit(
"DELEGATION_REVOKED",
{
"event_code": "0x3003",
"delegation_id": delegation_id,
"reason": reason,
"timestamp": datetime.now(UTC).isoformat(),
},
)
logger.info("Delegation revoked: %s (%s)", delegation_id, reason)
return True
async def cleanup_expired(self) -> int:
"""Expire stale delegations and clean up IdP registrations."""
expired = await expire_stale()
for d in expired:
if d.agent_keycloak_client_id:
await self.registrar.delete_agent(d.agent_keycloak_client_id)
await chronicle.emit(
"DELEGATION_EXPIRED",
{
"event_code": "0x3004",
"delegation_id": d.delegation_id,
"timestamp": datetime.now(UTC).isoformat(),
},
)
if expired:
logger.info("Expired %d stale delegations", len(expired))
return len(expired)
def _capability_mask_for(ceiling: str) -> int:
"""Convert a capability ceiling string to the GSAP capability mask bits."""
bits = {"CAP_READ": 1, "CAP_PROPOSE": 2, "CAP_MUTATE": 4, "CAP_ADMIN": 8}
if ceiling in bits:
# Ceiling is inclusive: CAP_MUTATE means READ | PROPOSE | MUTATE.
order = ["CAP_READ", "CAP_PROPOSE", "CAP_MUTATE", "CAP_ADMIN"]
idx = order.index(ceiling)
mask = 0
for cap in order[: idx + 1]:
mask |= bits[cap]
return mask
return 1 # default to READ