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>
241 lines
8.5 KiB
Python
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
|