"""Core delegation lifecycle — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §3.""" import logging import uuid from datetime import datetime, timedelta from llm_broker import chronicle, db from llm_broker.db import DelegationDB from llm_broker.gsap import GSAPClient from llm_broker.models import ( AgentPrincipal, DelegationRequest, DelegationResponse, DelegationScope, ) from llm_broker.registrar_factory import create_registrar from llm_broker.settings import Settings logger = logging.getLogger(__name__) class DelegationManager: def __init__(self, config: Settings): self.config = config self.registrar = create_registrar(config) self.gsap = GSAPClient(config.gsap_broker_url, config.gsap_bearer_token) async def create_delegation( self, request: DelegationRequest, delegator_did: str ) -> DelegationResponse: delegation_id = f"del-{uuid.uuid4().hex[:8]}" scope = request.scope or DelegationScope() now = datetime.utcnow() 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. Request delegated AC from GSAP broker try: ac_result = await self.gsap.request_delegated_ac( delegator_ac_id=request.delegator_ac_id, agent_did=agent_did, delegation_id=delegation_id, corpus_entry_cid="sha256:dev-jumphost", capability_ceiling=scope.capability_ceiling, ttl_minutes=scope.max_ttl_minutes, ) except Exception as e: await self.registrar.delete_agent(credentials.client_id) raise RuntimeError(f"Failed to request delegated AC: {e}") # 3. Record in Chronicle chronicle_cid = await chronicle.delegation_created( delegation_id=delegation_id, delegator_did=delegator_did, agent_did=agent_did, agent_type=request.agent_type, scope=scope.model_dump(), ) # 4. Persist 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, expires_at=expires_at, chronicle_cid=chronicle_cid or None, ) await db.create_delegation(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 revoke_delegation(self, delegation_id: str, reason: str) -> bool: delegation = await db.get_delegation(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(delegation_id, reason) await chronicle.delegation_revoked(delegation_id, reason) 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 db.expire_stale() for d in expired: if d.agent_keycloak_client_id: await self.registrar.delete_agent(d.agent_keycloak_client_id) await chronicle.delegation_expired(d.delegation_id) if expired: logger.info("Expired %d stale delegations", len(expired)) return len(expired)