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>
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
"""FastAPI router for delegation lifecycle.
|
|
|
|
Endpoints (originally from llm-principal-broker, now in-process):
|
|
POST /delegations/ create_delegation §8.1
|
|
POST /delegations/{id}/revoke revoke_delegation §8.2
|
|
GET /delegations/{id} get_delegation §8.3
|
|
GET /delegations/ list_delegations §8.4
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Header, HTTPException
|
|
|
|
from .lifecycle import DelegationManager
|
|
from .models import (
|
|
ActiveDelegation,
|
|
AgentListResponse,
|
|
DelegationInfo,
|
|
DelegationRequest,
|
|
DelegationResponse,
|
|
DelegationStatus,
|
|
RevokeRequest,
|
|
RevokeResponse,
|
|
)
|
|
from .storage import get_active_delegations, get_delegation as db_get
|
|
|
|
router = APIRouter(prefix="/delegations", tags=["Delegations"])
|
|
|
|
# A single DelegationManager instance is shared across requests. It holds the
|
|
# AgentRegistrar (Keycloak/Entra/Stub) and is constructed once at import time.
|
|
manager = DelegationManager()
|
|
|
|
|
|
@router.post("/", response_model=DelegationResponse)
|
|
async def create_delegation(
|
|
request: DelegationRequest,
|
|
x_delegator_did: str = Header(..., alias="X-Delegator-DID"),
|
|
):
|
|
"""Request delegation of authority to an AI agent (§8.1)."""
|
|
try:
|
|
return await manager.create_delegation(request, x_delegator_did)
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
|
|
|
|
@router.post("/{delegation_id}/revoke", response_model=RevokeResponse)
|
|
async def revoke_delegation(
|
|
delegation_id: str,
|
|
request: RevokeRequest = RevokeRequest(),
|
|
):
|
|
"""Revoke an active delegation (§8.2)."""
|
|
success = await manager.revoke_delegation(delegation_id, request.reason)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Delegation not found or not active")
|
|
return RevokeResponse(delegation_id=delegation_id, reason=request.reason)
|
|
|
|
|
|
@router.get("/{delegation_id}", response_model=DelegationInfo)
|
|
async def get_delegation(delegation_id: str):
|
|
"""Query delegation status (§8.3)."""
|
|
d = await db_get(delegation_id)
|
|
if not d:
|
|
raise HTTPException(status_code=404, detail="Delegation not found")
|
|
|
|
now = datetime.utcnow()
|
|
ttl_remaining = max(0, int((d.expires_at - now).total_seconds()))
|
|
|
|
return DelegationInfo(
|
|
delegation_id=d.delegation_id,
|
|
status=DelegationStatus(d.status),
|
|
agent_did=d.agent_did,
|
|
agent_type=d.agent_type,
|
|
delegator_did=d.delegator_did,
|
|
commands_executed=d.commands_executed,
|
|
commands_remaining=max(0, d.max_commands - d.commands_executed),
|
|
ttl_remaining_seconds=ttl_remaining,
|
|
created_at=d.created_at.isoformat(),
|
|
expires_at=d.expires_at.isoformat(),
|
|
)
|
|
|
|
|
|
@router.get("/", response_model=AgentListResponse)
|
|
async def list_delegations():
|
|
"""List all active agent delegations (§8.4)."""
|
|
active = await get_active_delegations()
|
|
now = datetime.utcnow()
|
|
|
|
return AgentListResponse(
|
|
active_delegations=[
|
|
ActiveDelegation(
|
|
delegation_id=d.delegation_id,
|
|
agent_type=d.agent_type,
|
|
delegator=d.delegator_did,
|
|
commands_executed=d.commands_executed,
|
|
ttl_remaining_seconds=max(
|
|
0, int((d.expires_at - now).total_seconds())
|
|
),
|
|
status=d.status,
|
|
)
|
|
for d in active
|
|
],
|
|
total_active=len(active),
|
|
)
|