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>
146 lines
4.4 KiB
Python
146 lines
4.4 KiB
Python
"""LLM Principal Broker — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001.
|
|
|
|
AI agent identity delegation for governed shell sessions.
|
|
Companion service to the GSAP broker.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
|
|
import structlog
|
|
from fastapi import FastAPI, HTTPException, Header
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from llm_broker import db
|
|
from llm_broker.delegation import DelegationManager
|
|
from llm_broker.models import (
|
|
ActiveDelegation,
|
|
AgentListResponse,
|
|
DelegationInfo,
|
|
DelegationRequest,
|
|
DelegationResponse,
|
|
DelegationStatus,
|
|
RevokeRequest,
|
|
RevokeResponse,
|
|
)
|
|
from llm_broker.settings import settings
|
|
|
|
logger = structlog.get_logger()
|
|
manager = DelegationManager(settings)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await db.init_db()
|
|
logger.info("llm-principal-broker started", broker_did=settings.broker_did)
|
|
task = asyncio.create_task(_cleanup_loop())
|
|
yield
|
|
task.cancel()
|
|
|
|
|
|
async def _cleanup_loop():
|
|
"""Periodically expire stale delegations (30s interval)."""
|
|
while True:
|
|
try:
|
|
count = await manager.cleanup_expired()
|
|
except Exception as e:
|
|
logger.warning("cleanup error", error=str(e))
|
|
await asyncio.sleep(30)
|
|
|
|
|
|
app = FastAPI(
|
|
title="LLM Principal Broker",
|
|
description="AI agent identity delegation — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.post("/delegate", response_model=DelegationResponse, tags=["Delegation"])
|
|
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))
|
|
|
|
|
|
@app.post("/delegate/{delegation_id}/revoke", response_model=RevokeResponse, tags=["Delegation"])
|
|
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)
|
|
|
|
|
|
@app.get("/delegate/{delegation_id}", response_model=DelegationInfo, tags=["Delegation"])
|
|
async def get_delegation(delegation_id: str):
|
|
"""Query delegation status — §8.3."""
|
|
d = await db.get_delegation(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(),
|
|
)
|
|
|
|
|
|
@app.get("/agents", response_model=AgentListResponse, tags=["Delegation"])
|
|
async def list_agents():
|
|
"""List all active agent delegations — §8.4."""
|
|
active = await db.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),
|
|
)
|
|
|
|
|
|
@app.get("/health", tags=["Health"])
|
|
async def health():
|
|
active = await db.get_active_delegations()
|
|
return {
|
|
"status": "healthy",
|
|
"service": "llm-principal-broker",
|
|
"version": "0.1.0",
|
|
"active_delegations": len(active),
|
|
}
|