fastapi-gsap/gsap_broker/delegations/lifecycle.py
Tyler King f7c49387c1 feat: absorb llm-principal-broker as gsap_broker/delegations/
Merges the standalone llm-principal-broker (1,132 LOC) into fastapi-gsap
as an in-process module. The previous architecture had two FastAPI
processes where the broker called GSAP over HTTP on every delegation
creation; now the lifecycle code uses GSAP's own async DB engine
directly and inserts AuthorizationContextDB rows in the same
transaction context.

New module: gsap_broker/delegations/
  models.py             Pydantic request/response shapes
  storage.py            DelegationDB SQLModel sharing the GSAP engine
  lifecycle.py          DelegationManager — in-process AC issuance via
                        AuthorizationContextDB.insert (no HTTP self-call)
  cleanup.py            30s background task for stale delegations
  router.py             /delegations/* FastAPI router (4 endpoints)
  registrars/
    base.py             AgentRegistrar Protocol + AgentCredentials
    stub.py             dev-mode no-op
    keycloak.py         Keycloak Admin REST API
    entra.py            Microsoft Entra Agent ID via Graph (lazy import)
    factory.py          driver selection (auto/stub/keycloak/entra)

Wiring:
  app.py mounts the delegations router and starts the cleanup task in
  the existing lifespan context manager.
  settings.py absorbs the keycloak_admin_*, entra_*, and
  agent_registrar fields from the old broker's settings.
  pyproject.toml adds an optional `entra` extra for the msal dep.

Behaviour preservation:
  - Endpoints kept identical: POST /, POST /{id}/revoke, GET /{id}, GET /
  - Chronicle event codes preserved: 0x3001 / 0x3003 / 0x3004
  - DelegationScope defaults unchanged (max_ttl_minutes=60, max_commands=500)
  - Capability ceiling -> capability_mask conversion documented inline

Smoke test: `python -c "from gsap_broker.app import app"` loads cleanly
with 26 routes including the four /delegations/ endpoints.

The standalone llm-principal-broker repo is archived to
~/projects/archive/llm-principal-broker.

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

241 lines
8.5 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
) -> DelegationResponse:
delegation_id = f"del-{uuid.uuid4().hex[:8]}"
scope = request.scope or DelegationScope()
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