fastapi-gsap/gsap_broker/delegations/models.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

96 lines
2.4 KiB
Python

"""Pydantic models for delegation lifecycle.
Originally from llm-principal-broker (GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001
§3, §8). Absorbed into fastapi-gsap as the delegations submodule so that
the lifecycle bookkeeping shares process and database with the AC issuance
endpoints. The previous standalone service made an HTTP call back to GSAP
on every delegation creation; now it is an in-process function call.
"""
from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum
class DelegationStatus(str, Enum):
REQUESTED = "requested"
ACTIVE = "active"
EXPIRED = "expired"
REVOKED = "revoked"
class DelegationScope(BaseModel):
inherit_corpus: bool = True
inherit_contexts: bool = True
capability_ceiling: str = "CAP_MUTATE"
ceremony_required_for: list[str] = Field(
default_factory=lambda: ["delete", "destroy", "drop"]
)
prohibited_commands: list[str] = Field(default_factory=list)
max_ttl_minutes: int = 60
max_commands: int = 500
class DelegationRequest(BaseModel):
"""POST /delegations/ request body — §8.1."""
delegator_ac_id: str
agent_type: str = "claude-code"
agent_model: Optional[str] = None
scope: Optional[DelegationScope] = None
accord_template: str = "ai-delegation-standard"
class AgentPrincipal(BaseModel):
did: str
keycloak_client_id: str
display_name: str
class DelegationResponse(BaseModel):
"""POST /delegations/ response — §3.2."""
delegation_id: str
agent_principal: AgentPrincipal
delegated_ac: dict
agent_token: str
expires_at: str
max_commands: int
chronicle_cid: Optional[str] = None
class DelegationInfo(BaseModel):
"""GET /delegations/{id} response — §8.3."""
delegation_id: str
status: DelegationStatus
agent_did: str
agent_type: str
delegator_did: str
commands_executed: int
commands_remaining: int
ttl_remaining_seconds: int
created_at: str
expires_at: str
class RevokeRequest(BaseModel):
reason: str = "manual_revocation"
class RevokeResponse(BaseModel):
delegation_id: str
status: str = "revoked"
reason: str
chronicle_cid: Optional[str] = None
class ActiveDelegation(BaseModel):
delegation_id: str
agent_type: str
delegator: str
commands_executed: int
ttl_remaining_seconds: int
status: str
class AgentListResponse(BaseModel):
active_delegations: list[ActiveDelegation]
total_active: int