feat: fastapi-gsap — lightweight GSAP broker PoC
Reference implementation of GCAP-SPEC-SHELLBOUND-BROKER-0001
in FastAPI. Designed for ISVs and enterprises implementing
the governed shell authorization protocol.
Architecture:
FastAPI + SQLModel + Pydantic + async SQLite
Single container deployment: Dockerfile included
OpenAPI schema at /docs is the machine-readable GSAP contract
Broker Interface (§5):
POST /governance/authorize/ — Issue AC
GET /governance/authorize/{p}/ — Poll elevation
POST /governance/complete/ — Receive CR
GET /governance/session/{id}/ — View chain of custody
POST /governance/elevate/ — JIT elevation
GET /governance/drivers/ — List drivers
Identity Driver Interface (§2.2):
IdentityDriver — abstract base (ISV extension point)
KeycloakDriver — Keycloak implementation
DriverRegistry — driver lookup and registration
Chronicle integration (§1.4):
Optional CloudEvents emission via CHRONICLE_WEBHOOK_URL
Forgejo push event format for receiver compatibility
Models:
Pydantic schemas for AC, CR, Principal, Accord, Operation
SQLModel DB models for persistence
Tests: 6 async tests including full AC→CR cycle
695 lines across 27 files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
11ec21f311
commit
cbc9ad73f7
27 changed files with 695 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "gsap_broker.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
gsap_broker/__init__.py
Normal file
0
gsap_broker/__init__.py
Normal file
27
gsap_broker/app.py
Normal file
27
gsap_broker/app.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""fastapi-gsap: Lightweight GSAP broker — GCAP-SPEC-SHELLBOUND-BROKER-0001."""
|
||||||
|
import structlog
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from gsap_broker.settings import settings
|
||||||
|
from gsap_broker.db import init_db
|
||||||
|
from gsap_broker.routers import authorize, complete, session, elevate, health, drivers
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
logger.info("fastapi-gsap started", broker_did=settings.broker_did)
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(title="fastapi-gsap", description="GSAP broker PoC — GCAP-SPEC-SHELLBOUND-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.include_router(authorize.router, prefix="/governance", tags=["AC"])
|
||||||
|
app.include_router(complete.router, prefix="/governance", tags=["CR"])
|
||||||
|
app.include_router(session.router, prefix="/governance", tags=["Session"])
|
||||||
|
app.include_router(elevate.router, prefix="/governance", tags=["Elevation"])
|
||||||
|
app.include_router(drivers.router, prefix="/governance", tags=["Drivers"])
|
||||||
|
app.include_router(health.router, tags=["Health"])
|
||||||
1
gsap_broker/chronicle/__init__.py
Normal file
1
gsap_broker/chronicle/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .client import emit
|
||||||
26
gsap_broker/chronicle/client.py
Normal file
26
gsap_broker/chronicle/client.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Chronicle CloudEvents client. Optional per GSAP §1.4."""
|
||||||
|
import hashlib, json, logging
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
import httpx
|
||||||
|
from gsap_broker.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def emit(kind: str, payload: dict) -> str:
|
||||||
|
url = settings.chronicle_webhook_url
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
event_json = json.dumps({"kind": kind, **payload}, sort_keys=True, default=str)
|
||||||
|
cid = "sha256:" + hashlib.sha256(event_json.encode()).hexdigest()
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
await client.post(url, json={
|
||||||
|
"pusher": {"login": payload.get("principal_did", settings.broker_did)},
|
||||||
|
"ref": f"refs/gsap/{kind}",
|
||||||
|
"repository": {"full_name": "gsap-broker/governance"},
|
||||||
|
"commits": [{"message": f"{kind}: {json.dumps(payload, default=str)}"}],
|
||||||
|
}, headers={"X-Forgejo-Event": "push"})
|
||||||
|
return cid
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Chronicle emit failed: {kind}: {e}")
|
||||||
|
return ""
|
||||||
14
gsap_broker/db.py
Normal file
14
gsap_broker/db.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from gsap_broker.settings import settings
|
||||||
|
|
||||||
|
engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
async def get_session():
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
yield session
|
||||||
54
gsap_broker/db_models.py
Normal file
54
gsap_broker/db_models.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel, Column
|
||||||
|
from sqlalchemy import JSON
|
||||||
|
|
||||||
|
class AuthorizationContextDB(SQLModel, table=True):
|
||||||
|
__tablename__ = "authorization_contexts"
|
||||||
|
context_id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
principal_did: str
|
||||||
|
driver_id: str
|
||||||
|
playbook: str
|
||||||
|
corpus_entry_cid: str
|
||||||
|
parameters_cid: str
|
||||||
|
accord_template: str
|
||||||
|
capability_mask: int = 3
|
||||||
|
idp_vendor: str = "keycloak"
|
||||||
|
token_jti: str = ""
|
||||||
|
elevation_active: list = Field(default=[], sa_column=Column(JSON))
|
||||||
|
mfa_satisfied: bool = False
|
||||||
|
status: str = "authorized"
|
||||||
|
issued_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
expires_at: datetime
|
||||||
|
consumed_at: Optional[datetime] = None
|
||||||
|
poll_token: Optional[str] = None
|
||||||
|
chronicle_event_cid: str = ""
|
||||||
|
|
||||||
|
class CompletionReceiptDB(SQLModel, table=True):
|
||||||
|
__tablename__ = "completion_receipts"
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
context_id: uuid.UUID = Field(foreign_key="authorization_contexts.context_id")
|
||||||
|
outcome: str
|
||||||
|
completed_at: datetime
|
||||||
|
received_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
failure_reason: str = ""
|
||||||
|
chronicle_session_id: str = ""
|
||||||
|
chronicle_events: list = Field(default=[], sa_column=Column(JSON))
|
||||||
|
merkle_root: str = ""
|
||||||
|
behavioral_attestation_status: str = "unavailable"
|
||||||
|
ffc_did: str = ""
|
||||||
|
ffc_signature: str = ""
|
||||||
|
signature_verified: bool = False
|
||||||
|
chronicle_event_cid: str = ""
|
||||||
|
|
||||||
|
class ElevationRequestDB(SQLModel, table=True):
|
||||||
|
__tablename__ = "elevation_requests"
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
principal_id: str = ""
|
||||||
|
role_name: str
|
||||||
|
justification: str = ""
|
||||||
|
duration_minutes: int = 60
|
||||||
|
status: str = "pending"
|
||||||
|
requested_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
3
gsap_broker/drivers/__init__.py
Normal file
3
gsap_broker/drivers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .base import IdentityDriver, AuthResult
|
||||||
|
from .keycloak import KeycloakDriver
|
||||||
|
from .registry import DriverRegistry
|
||||||
39
gsap_broker/drivers/base.py
Normal file
39
gsap_broker/drivers/base.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Identity Driver Interface — GSAP §2.2. Zero framework imports."""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ElevationRequired:
|
||||||
|
role: str
|
||||||
|
activation_url: str
|
||||||
|
instructions: str
|
||||||
|
mechanism: str = "custom"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuthResult:
|
||||||
|
STATUS_AUTHORIZED = "authorized"
|
||||||
|
STATUS_PENDING_ELEVATION = "pending_elevation"
|
||||||
|
STATUS_DENIED = "denied"
|
||||||
|
|
||||||
|
status: str
|
||||||
|
principal_did: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
stable_id: str = ""
|
||||||
|
token_jti: str = ""
|
||||||
|
elevation_active: list = field(default_factory=list)
|
||||||
|
mfa_satisfied: bool = False
|
||||||
|
elevation_required: Optional[ElevationRequired] = None
|
||||||
|
denial_reason: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authorized(self): return self.status == self.STATUS_AUTHORIZED
|
||||||
|
@property
|
||||||
|
def needs_elevation(self): return self.status == self.STATUS_PENDING_ELEVATION
|
||||||
|
|
||||||
|
class IdentityDriver(ABC):
|
||||||
|
def __init__(self, config: dict): self.config = config
|
||||||
|
@abstractmethod
|
||||||
|
async def authenticate(self) -> AuthResult: ...
|
||||||
|
@abstractmethod
|
||||||
|
async def revoke(self, session_id: str) -> None: ...
|
||||||
43
gsap_broker/drivers/keycloak.py
Normal file
43
gsap_broker/drivers/keycloak.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Keycloak identity driver — GSAP §2.2."""
|
||||||
|
import logging
|
||||||
|
from .base import IdentityDriver, AuthResult, ElevationRequired
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class KeycloakDriver(IdentityDriver):
|
||||||
|
async def authenticate(self) -> AuthResult:
|
||||||
|
token_data = self.config.get("_token_data", {})
|
||||||
|
if not token_data:
|
||||||
|
return AuthResult(status=AuthResult.STATUS_DENIED, denial_reason="No token in context.")
|
||||||
|
|
||||||
|
realm_roles = token_data.get("realm_access", {}).get("roles", [])
|
||||||
|
requested_accord = self.config.get("requested_accord", "")
|
||||||
|
required_role = self.config.get("accord_roles", {}).get(requested_accord, "")
|
||||||
|
suffix = self.config.get("elevated_suffix", "-elevated")
|
||||||
|
elevation_active = [r for r in realm_roles if r.endswith(suffix)]
|
||||||
|
|
||||||
|
if required_role and required_role not in realm_roles:
|
||||||
|
return AuthResult(
|
||||||
|
status=AuthResult.STATUS_PENDING_ELEVATION,
|
||||||
|
elevation_required=ElevationRequired(
|
||||||
|
role=required_role,
|
||||||
|
activation_url="/governance/elevate/",
|
||||||
|
instructions=f"Request elevation to '{required_role}' via POST /governance/elevate/",
|
||||||
|
))
|
||||||
|
|
||||||
|
stable_id = token_data.get("sub", "")
|
||||||
|
template = self.config.get("did_template", "did:web:{domain}/principal/{alias}")
|
||||||
|
domain = self.config.get("domain", "example.com")
|
||||||
|
alias = token_data.get("preferred_username", stable_id)
|
||||||
|
|
||||||
|
return AuthResult(
|
||||||
|
status=AuthResult.STATUS_AUTHORIZED,
|
||||||
|
principal_did=template.format(stable_id=stable_id, domain=domain, alias=alias),
|
||||||
|
display_name=token_data.get("name", alias),
|
||||||
|
stable_id=stable_id, token_jti=token_data.get("jti", ""),
|
||||||
|
elevation_active=elevation_active,
|
||||||
|
mfa_satisfied="otp" in token_data.get("amr", []) or "mfa" in token_data.get("amr", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke(self, session_id: str) -> None:
|
||||||
|
logger.info(f"Keycloak revoke: {session_id}")
|
||||||
21
gsap_broker/drivers/registry.py
Normal file
21
gsap_broker/drivers/registry.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Driver Registry — GSAP §2.5."""
|
||||||
|
from .base import IdentityDriver
|
||||||
|
from .keycloak import KeycloakDriver
|
||||||
|
|
||||||
|
_DRIVERS: dict[str, type[IdentityDriver]] = {"keycloak": KeycloakDriver}
|
||||||
|
|
||||||
|
class DriverRegistry:
|
||||||
|
@staticmethod
|
||||||
|
def get(driver_id: str, config: dict) -> IdentityDriver:
|
||||||
|
for vendor, cls in _DRIVERS.items():
|
||||||
|
if driver_id.startswith(vendor) or driver_id == vendor:
|
||||||
|
return cls(config)
|
||||||
|
raise KeyError(f"Driver '{driver_id}' not found. Available: {list(_DRIVERS.keys())}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def register(driver_id: str, cls: type[IdentityDriver]) -> None:
|
||||||
|
_DRIVERS[driver_id] = cls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_drivers() -> list[str]:
|
||||||
|
return list(_DRIVERS.keys())
|
||||||
1
gsap_broker/models/__init__.py
Normal file
1
gsap_broker/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .gsap import *
|
||||||
121
gsap_broker/models/gsap.py
Normal file
121
gsap_broker/models/gsap.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""GSAP Pydantic models — GCAP-SPEC-SHELLBOUND-BROKER-0001."""
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class ACStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
AUTHORIZED = "authorized"
|
||||||
|
CONSUMED = "consumed"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
class Outcome(str, Enum):
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
VIOLATED = "violated"
|
||||||
|
TIMED_OUT = "timed_out"
|
||||||
|
|
||||||
|
class BehaviorStatus(str, Enum):
|
||||||
|
VERIFIED = "verified"
|
||||||
|
VIOLATED = "violated"
|
||||||
|
UNAVAILABLE = "unavailable"
|
||||||
|
|
||||||
|
class Principal(BaseModel):
|
||||||
|
did: str
|
||||||
|
display_name: str = ""
|
||||||
|
broker_session_id: str = ""
|
||||||
|
driver_id: str = ""
|
||||||
|
|
||||||
|
class Accord(BaseModel):
|
||||||
|
template: str
|
||||||
|
capability_mask: int = 3
|
||||||
|
|
||||||
|
class Operation(BaseModel):
|
||||||
|
playbook: str
|
||||||
|
corpus_entry_cid: str
|
||||||
|
parameters_cid: str
|
||||||
|
apply_authorized_cid: Optional[str] = None
|
||||||
|
|
||||||
|
class IdentityProof(BaseModel):
|
||||||
|
idp_vendor: str = "keycloak"
|
||||||
|
token_jti: str = ""
|
||||||
|
elevation_active: list[str] = []
|
||||||
|
mfa_satisfied: bool = False
|
||||||
|
|
||||||
|
class AuthorizationContext(BaseModel):
|
||||||
|
gsap_version: str = "0.1.0"
|
||||||
|
context_id: UUID = Field(default_factory=uuid4)
|
||||||
|
issued_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
principal: Principal
|
||||||
|
accord: Accord
|
||||||
|
operation: Operation
|
||||||
|
identity_proof: IdentityProof
|
||||||
|
broker: dict = Field(default_factory=dict)
|
||||||
|
signature: Optional[dict] = None
|
||||||
|
|
||||||
|
class ChronicleEvidence(BaseModel):
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
events: list[dict] = []
|
||||||
|
merkle_root: Optional[str] = None
|
||||||
|
|
||||||
|
class BehavioralAttestation(BaseModel):
|
||||||
|
status: BehaviorStatus = BehaviorStatus.UNAVAILABLE
|
||||||
|
observed_behavior_cid: Optional[str] = None
|
||||||
|
declared_behavior_cid: Optional[str] = None
|
||||||
|
|
||||||
|
class AuthorizeRequest(BaseModel):
|
||||||
|
playbook: str
|
||||||
|
corpus_entry_cid: str
|
||||||
|
parameters_cid: str
|
||||||
|
accord_template: str
|
||||||
|
driver_id: str
|
||||||
|
|
||||||
|
class AuthorizeResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
authorization_context: Optional[AuthorizationContext] = None
|
||||||
|
poll_token: Optional[str] = None
|
||||||
|
elevation_instructions: Optional[str] = None
|
||||||
|
activation_url: Optional[str] = None
|
||||||
|
|
||||||
|
class CompleteRequest(BaseModel):
|
||||||
|
context_id: UUID
|
||||||
|
outcome: Outcome
|
||||||
|
completed_at: datetime
|
||||||
|
failure_reason: Optional[str] = None
|
||||||
|
chronicle_session_id: Optional[str] = None
|
||||||
|
chronicle_evidence: ChronicleEvidence = Field(default_factory=ChronicleEvidence)
|
||||||
|
behavioral_attestation: BehavioralAttestation = Field(default_factory=BehavioralAttestation)
|
||||||
|
ffc: dict = Field(default_factory=dict)
|
||||||
|
signature: Optional[dict] = None
|
||||||
|
|
||||||
|
class CompleteResponse(BaseModel):
|
||||||
|
status: str = "received"
|
||||||
|
receipt_id: UUID
|
||||||
|
signature_verified: bool
|
||||||
|
chronicle_event_cid: Optional[str] = None
|
||||||
|
|
||||||
|
class SessionResponse(BaseModel):
|
||||||
|
context_id: UUID
|
||||||
|
principal_did: str
|
||||||
|
accord_template: str
|
||||||
|
playbook: str
|
||||||
|
status: ACStatus
|
||||||
|
issued_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
consumed_at: Optional[datetime] = None
|
||||||
|
chronicle_event_cid: Optional[str] = None
|
||||||
|
completion_receipt: Optional[dict] = None
|
||||||
|
|
||||||
|
class ElevateRequest(BaseModel):
|
||||||
|
role_name: str
|
||||||
|
justification: str = ""
|
||||||
|
duration_minutes: int = Field(default=60, ge=5, le=480)
|
||||||
|
|
||||||
|
class ElevateResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
elevation_id: Optional[str] = None
|
||||||
|
message: str = ""
|
||||||
0
gsap_broker/routers/__init__.py
Normal file
0
gsap_broker/routers/__init__.py
Normal file
78
gsap_broker/routers/authorize.py
Normal file
78
gsap_broker/routers/authorize.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""POST /governance/authorize/ — GSAP §5.2"""
|
||||||
|
import secrets, uuid
|
||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from gsap_broker.db import get_session
|
||||||
|
from gsap_broker.db_models import AuthorizationContextDB
|
||||||
|
from gsap_broker.drivers.registry import DriverRegistry
|
||||||
|
from gsap_broker.models import (
|
||||||
|
AuthorizeRequest, AuthorizeResponse, AuthorizationContext,
|
||||||
|
Principal, Accord, Operation, IdentityProof)
|
||||||
|
from gsap_broker.settings import settings
|
||||||
|
from gsap_broker import chronicle
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/authorize/", response_model=AuthorizeResponse, summary="Issue AC (GSAP §5.2)")
|
||||||
|
async def authorize(request: AuthorizeRequest, db: AsyncSession = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
driver = DriverRegistry.get(request.driver_id, config={
|
||||||
|
"requested_accord": request.accord_template,
|
||||||
|
"domain": settings.keycloak_domain,
|
||||||
|
"did_template": settings.keycloak_did_template,
|
||||||
|
"elevated_suffix": settings.keycloak_elevated_role_suffix,
|
||||||
|
})
|
||||||
|
except KeyError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
auth_result = await driver.authenticate()
|
||||||
|
|
||||||
|
if auth_result.needs_elevation:
|
||||||
|
poll_token = secrets.token_urlsafe(32)
|
||||||
|
ac_db = AuthorizationContextDB(
|
||||||
|
principal_did="", driver_id=request.driver_id, playbook=request.playbook,
|
||||||
|
corpus_entry_cid=request.corpus_entry_cid, parameters_cid=request.parameters_cid,
|
||||||
|
accord_template=request.accord_template, status="pending",
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(minutes=10), poll_token=poll_token)
|
||||||
|
db.add(ac_db); await db.commit()
|
||||||
|
return AuthorizeResponse(
|
||||||
|
status="pending_elevation", poll_token=poll_token,
|
||||||
|
elevation_instructions=auth_result.elevation_required.instructions,
|
||||||
|
activation_url=auth_result.elevation_required.activation_url)
|
||||||
|
|
||||||
|
if not auth_result.is_authorized:
|
||||||
|
raise HTTPException(status_code=403, detail=auth_result.denial_reason)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
expires = now + timedelta(minutes=settings.ac_ttl_minutes)
|
||||||
|
ctx_id = uuid.uuid4()
|
||||||
|
|
||||||
|
ac = AuthorizationContext(
|
||||||
|
context_id=ctx_id, issued_at=now, expires_at=expires,
|
||||||
|
principal=Principal(did=auth_result.principal_did, display_name=auth_result.display_name, driver_id=request.driver_id),
|
||||||
|
accord=Accord(template=request.accord_template),
|
||||||
|
operation=Operation(playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid, parameters_cid=request.parameters_cid),
|
||||||
|
identity_proof=IdentityProof(token_jti=auth_result.token_jti, elevation_active=auth_result.elevation_active, mfa_satisfied=auth_result.mfa_satisfied),
|
||||||
|
broker={"did": settings.broker_did, "name": settings.broker_name})
|
||||||
|
|
||||||
|
ac_db = AuthorizationContextDB(
|
||||||
|
context_id=ctx_id, principal_did=auth_result.principal_did, driver_id=request.driver_id,
|
||||||
|
playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid,
|
||||||
|
parameters_cid=request.parameters_cid, accord_template=request.accord_template,
|
||||||
|
token_jti=auth_result.token_jti, elevation_active=auth_result.elevation_active,
|
||||||
|
mfa_satisfied=auth_result.mfa_satisfied, status="authorized", issued_at=now, expires_at=expires)
|
||||||
|
db.add(ac_db)
|
||||||
|
cid = await chronicle.emit("GSAP_AC_ISSUED", {"event_code": "0x2704", "context_id": str(ctx_id), "principal_did": auth_result.principal_did, "playbook": request.playbook})
|
||||||
|
ac_db.chronicle_event_cid = cid
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return AuthorizeResponse(status="authorized", authorization_context=ac)
|
||||||
|
|
||||||
|
@router.get("/authorize/{poll_token}/", response_model=AuthorizeResponse, summary="Poll (GSAP §5.3)")
|
||||||
|
async def authorize_poll(poll_token: str, db: AsyncSession = Depends(get_session)):
|
||||||
|
from sqlmodel import select
|
||||||
|
result = await db.exec(select(AuthorizationContextDB).where(AuthorizationContextDB.poll_token == poll_token))
|
||||||
|
ac_db = result.first()
|
||||||
|
if not ac_db: raise HTTPException(status_code=404, detail="Not found.")
|
||||||
|
return AuthorizeResponse(status=ac_db.status, poll_token=poll_token)
|
||||||
39
gsap_broker/routers/complete.py
Normal file
39
gsap_broker/routers/complete.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""POST /governance/complete/ — GSAP §5.4"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from sqlmodel import select
|
||||||
|
from gsap_broker.db import get_session
|
||||||
|
from gsap_broker.db_models import AuthorizationContextDB, CompletionReceiptDB
|
||||||
|
from gsap_broker.models import CompleteRequest, CompleteResponse
|
||||||
|
from gsap_broker import chronicle
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/complete/", response_model=CompleteResponse, summary="Receive CR (GSAP §5.4)")
|
||||||
|
async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_session)):
|
||||||
|
result = await db.exec(select(AuthorizationContextDB).where(
|
||||||
|
AuthorizationContextDB.context_id == request.context_id,
|
||||||
|
AuthorizationContextDB.status == "authorized"))
|
||||||
|
ac_db = result.first()
|
||||||
|
if not ac_db: raise HTTPException(status_code=404, detail="AC not found or already consumed.")
|
||||||
|
|
||||||
|
sig_verified = bool((request.signature or {}).get("value"))
|
||||||
|
cr_db = CompletionReceiptDB(
|
||||||
|
id=uuid.uuid4(), context_id=request.context_id, outcome=request.outcome.value,
|
||||||
|
completed_at=request.completed_at, failure_reason=request.failure_reason or "",
|
||||||
|
chronicle_session_id=request.chronicle_session_id or "",
|
||||||
|
chronicle_events=request.chronicle_evidence.events,
|
||||||
|
merkle_root=request.chronicle_evidence.merkle_root or "",
|
||||||
|
behavioral_attestation_status=request.behavioral_attestation.status.value,
|
||||||
|
ffc_did=request.ffc.get("did", ""), ffc_signature=(request.signature or {}).get("value", ""),
|
||||||
|
signature_verified=sig_verified)
|
||||||
|
db.add(cr_db)
|
||||||
|
ac_db.status = "consumed"; ac_db.consumed_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
cid = await chronicle.emit("GSAP_CR_RECEIVED", {"event_code": "0x2706", "context_id": str(request.context_id), "outcome": request.outcome.value})
|
||||||
|
cr_db.chronicle_event_cid = cid
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return CompleteResponse(receipt_id=cr_db.id, signature_verified=sig_verified, chronicle_event_cid=cid or None)
|
||||||
8
gsap_broker/routers/drivers.py
Normal file
8
gsap_broker/routers/drivers.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from gsap_broker.drivers.registry import DriverRegistry
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/drivers/", summary="List drivers (GSAP §2.5)")
|
||||||
|
async def list_drivers():
|
||||||
|
return {"drivers": DriverRegistry.list_drivers(), "gsap_version": "0.1.0"}
|
||||||
17
gsap_broker/routers/elevate.py
Normal file
17
gsap_broker/routers/elevate.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""POST /governance/elevate/ — JIT elevation"""
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from gsap_broker.db import get_session
|
||||||
|
from gsap_broker.db_models import ElevationRequestDB
|
||||||
|
from gsap_broker.models import ElevateRequest, ElevateResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/elevate/", response_model=ElevateResponse, summary="Request JIT elevation")
|
||||||
|
async def elevate(request: ElevateRequest, db: AsyncSession = Depends(get_session)):
|
||||||
|
er = ElevationRequestDB(id=uuid.uuid4(), role_name=request.role_name,
|
||||||
|
justification=request.justification, duration_minutes=request.duration_minutes)
|
||||||
|
db.add(er); await db.commit()
|
||||||
|
return ElevateResponse(status="pending", elevation_id=str(er.id),
|
||||||
|
message=f"Elevation to '{request.role_name}' requested.")
|
||||||
10
gsap_broker/routers/health.py
Normal file
10
gsap_broker/routers/health.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from gsap_broker.settings import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/health/")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "broker": settings.broker_name, "did": settings.broker_did,
|
||||||
|
"gsap_version": "0.1.0", "spec": "GCAP-SPEC-SHELLBOUND-BROKER-0001",
|
||||||
|
"chronicle": "enabled" if settings.chronicle_webhook_url else "disabled"}
|
||||||
31
gsap_broker/routers/session.py
Normal file
31
gsap_broker/routers/session.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""GET /governance/session/{id}/ — GSAP §5.5"""
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from sqlmodel import select
|
||||||
|
from gsap_broker.db import get_session
|
||||||
|
from gsap_broker.db_models import AuthorizationContextDB, CompletionReceiptDB
|
||||||
|
from gsap_broker.models import SessionResponse, ACStatus
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/session/{context_id}/", response_model=SessionResponse, summary="View session (GSAP §5.5)")
|
||||||
|
async def session_detail(context_id: uuid.UUID, db: AsyncSession = Depends(get_session)):
|
||||||
|
result = await db.exec(select(AuthorizationContextDB).where(AuthorizationContextDB.context_id == context_id))
|
||||||
|
ac_db = result.first()
|
||||||
|
if not ac_db: raise HTTPException(status_code=404, detail="Not found.")
|
||||||
|
|
||||||
|
cr_result = await db.exec(select(CompletionReceiptDB).where(CompletionReceiptDB.context_id == context_id))
|
||||||
|
cr_db = cr_result.first()
|
||||||
|
cr_data = None
|
||||||
|
if cr_db:
|
||||||
|
cr_data = {"outcome": cr_db.outcome, "behavioral_attestation": cr_db.behavioral_attestation_status,
|
||||||
|
"chronicle_session_id": cr_db.chronicle_session_id, "received_at": cr_db.received_at.isoformat(),
|
||||||
|
"signature_verified": cr_db.signature_verified}
|
||||||
|
|
||||||
|
return SessionResponse(
|
||||||
|
context_id=ac_db.context_id, principal_did=ac_db.principal_did,
|
||||||
|
accord_template=ac_db.accord_template, playbook=ac_db.playbook,
|
||||||
|
status=ACStatus(ac_db.status), issued_at=ac_db.issued_at,
|
||||||
|
expires_at=ac_db.expires_at, consumed_at=ac_db.consumed_at,
|
||||||
|
chronicle_event_cid=ac_db.chronicle_event_cid or None, completion_receipt=cr_data)
|
||||||
16
gsap_broker/settings.py
Normal file
16
gsap_broker/settings.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
|
||||||
|
broker_did: str = "did:web:gsap-broker.example.com"
|
||||||
|
broker_name: str = "fastapi-gsap"
|
||||||
|
ac_ttl_minutes: int = 30
|
||||||
|
chronicle_webhook_url: Optional[str] = None
|
||||||
|
keycloak_domain: str = "example.com"
|
||||||
|
keycloak_did_template: str = "did:web:{domain}/principal/{alias}"
|
||||||
|
keycloak_elevated_role_suffix: str = "-elevated"
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./gsap_broker.db"
|
||||||
|
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "fastapi-gsap"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Lightweight FastAPI GSAP broker — GCAP-SPEC-SHELLBOUND-BROKER-0001"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111.0",
|
||||||
|
"uvicorn[standard]>=0.29.0",
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"pydantic-settings>=2.2.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"sqlmodel>=0.0.19",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27", "ruff>=0.4", "pytest-mock>=3.14"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
pydantic>=2.7.0
|
||||||
|
pydantic-settings>=2.2.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
sqlmodel>=0.0.19
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
structlog>=24.1.0
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from gsap_broker.app import app
|
||||||
|
from gsap_broker import db as db_module
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def test_db():
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///./test_gsap.db")
|
||||||
|
db_module.engine = engine
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
yield
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
70
tests/test_broker.py
Normal file
70
tests/test_broker.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Tests for fastapi-gsap broker."""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health(client: AsyncClient):
|
||||||
|
resp = await client.get("/health/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["gsap_version"] == "0.1.0"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_drivers(client: AsyncClient):
|
||||||
|
resp = await client.get("/governance/drivers/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "keycloak" in resp.json()["drivers"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authorize_bad_driver(client: AsyncClient):
|
||||||
|
resp = await client.post("/governance/authorize/", json={
|
||||||
|
"playbook": "test", "corpus_entry_cid": "sha256:a", "parameters_cid": "sha256:b",
|
||||||
|
"accord_template": "test", "driver_id": "nonexistent"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authorize_no_token(client: AsyncClient):
|
||||||
|
resp = await client.post("/governance/authorize/", json={
|
||||||
|
"playbook": "test", "corpus_entry_cid": "sha256:a", "parameters_cid": "sha256:b",
|
||||||
|
"accord_template": "test", "driver_id": "keycloak"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_ac_cr_cycle(client: AsyncClient, mocker):
|
||||||
|
from gsap_broker.drivers.base import AuthResult
|
||||||
|
mocker.patch("gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
|
||||||
|
return_value=AuthResult(status=AuthResult.STATUS_AUTHORIZED,
|
||||||
|
principal_did="did:web:test/p/sam", token_jti="jti-1", mfa_satisfied=True))
|
||||||
|
|
||||||
|
auth_resp = await client.post("/governance/authorize/", json={
|
||||||
|
"playbook": "test-echo", "corpus_entry_cid": "sha256:" + "a"*64,
|
||||||
|
"parameters_cid": "sha256:" + "b"*64, "accord_template": "test-ops", "driver_id": "keycloak"})
|
||||||
|
assert auth_resp.status_code == 200
|
||||||
|
ctx_id = auth_resp.json()["authorization_context"]["context_id"]
|
||||||
|
|
||||||
|
cr_resp = await client.post("/governance/complete/", json={
|
||||||
|
"context_id": ctx_id, "outcome": "completed", "completed_at": "2026-01-01T00:00:00Z"})
|
||||||
|
assert cr_resp.status_code == 200
|
||||||
|
|
||||||
|
session_resp = await client.get(f"/governance/session/{ctx_id}/")
|
||||||
|
assert session_resp.status_code == 200
|
||||||
|
assert session_resp.json()["status"] == "consumed"
|
||||||
|
assert session_resp.json()["completion_receipt"]["outcome"] == "completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_consumed_ac_rejected(client: AsyncClient, mocker):
|
||||||
|
from gsap_broker.drivers.base import AuthResult
|
||||||
|
mocker.patch("gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
|
||||||
|
return_value=AuthResult(status=AuthResult.STATUS_AUTHORIZED,
|
||||||
|
principal_did="did:web:test/p/sam", token_jti="jti-2"))
|
||||||
|
|
||||||
|
auth_resp = await client.post("/governance/authorize/", json={
|
||||||
|
"playbook": "test", "corpus_entry_cid": "sha256:x", "parameters_cid": "sha256:y",
|
||||||
|
"accord_template": "test", "driver_id": "keycloak"})
|
||||||
|
ctx_id = auth_resp.json()["authorization_context"]["context_id"]
|
||||||
|
|
||||||
|
await client.post("/governance/complete/", json={
|
||||||
|
"context_id": ctx_id, "outcome": "completed", "completed_at": "2026-01-01T00:00:00Z"})
|
||||||
|
|
||||||
|
second = await client.post("/governance/complete/", json={
|
||||||
|
"context_id": ctx_id, "outcome": "completed", "completed_at": "2026-01-01T00:00:00Z"})
|
||||||
|
assert second.status_code == 404
|
||||||
Loading…
Reference in a new issue