"""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.keycloak import KeycloakAdmin from llm_broker.models import ( AgentPrincipal, DelegationRequest, DelegationResponse, DelegationScope, ) from llm_broker.settings import Settings logger = logging.getLogger(__name__) class DelegationManager: def __init__(self, config: Settings): self.config = config self.keycloak = KeycloakAdmin( config.keycloak_url, config.keycloak_realm, config.keycloak_admin_client_id, config.keycloak_admin_client_secret, ) 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}" agent_client_id = f"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 in Keycloak kc_result = await self.keycloak.register_agent_client( client_id=agent_client_id, display_name=agent_display, delegator_did=delegator_did, delegation_id=delegation_id, agent_type=request.agent_type, ) # 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.keycloak.delete_agent_client(agent_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=agent_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)", delegation_id, delegator_did, agent_did ) return DelegationResponse( delegation_id=delegation_id, agent_principal=AgentPrincipal( did=agent_did, keycloak_client_id=agent_client_id, display_name=agent_display, ), delegated_ac=ac_result, agent_token=kc_result.get("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.keycloak.delete_agent_client(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 Keycloak clients.""" expired = await db.expire_stale() for d in expired: if d.agent_keycloak_client_id: await self.keycloak.delete_agent_client(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)