"""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