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:
Tyler J King 2026-03-30 14:10:21 -04:00
parent 11ec21f311
commit cbc9ad73f7
27 changed files with 695 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.db
.env
dist/
*.egg-info/
.ruff_cache/
.pytest_cache/

7
Dockerfile Normal file
View 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
View file

27
gsap_broker/app.py Normal file
View 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"])

View file

@ -0,0 +1 @@
from .client import emit

View 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
View 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
View 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

View file

@ -0,0 +1,3 @@
from .base import IdentityDriver, AuthResult
from .keycloak import KeycloakDriver
from .registry import DriverRegistry

View 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: ...

View 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}")

View 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())

View file

@ -0,0 +1 @@
from .gsap import *

121
gsap_broker/models/gsap.py Normal file
View 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 = ""

View file

View 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)

View 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)

View 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"}

View 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.")

View 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"}

View 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
View 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
View 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
View 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
View file

21
tests/conftest.py Normal file
View 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
View 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