FastAPI companion service to the GSAP broker for AI agent
identity delegation in governed shell sessions.
Implements GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001:
POST /delegate — request delegation (human → AI agent)
POST /delegate/{id}/revoke — revoke delegation
GET /delegate/{id} — delegation status
GET /agents — list active delegations
GET /health — health check
Delegation lifecycle:
REQUESTED → ACTIVE → EXPIRED | REVOKED
Cascading revocation on delegator AC revocation
Background cleanup of expired delegations (30s interval)
Keycloak integration:
Registers ephemeral agent clients per delegation
Deletes clients on revocation/expiry
Dev mode: stubs when no client_secret configured
GSAP broker integration:
Requests delegated ACs via on_behalf_of pattern
Scope narrowing: agent ceiling ≤ delegator ceiling
Dev mode: stubs when no bearer_token configured
Chronicle integration:
DELEGATION_CREATED (0x3001)
DELEGATION_REVOKED (0x3003)
DELEGATION_EXPIRED (0x3004)
All 7 smoke tests pass (health, create, list, query, revoke, verify, empty).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
139 lines
5.1 KiB
Python
139 lines
5.1 KiB
Python
"""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)
|