diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b527e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.db +.env +dist/ +*.egg-info/ +.ruff_cache/ +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea81be2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/gsap_broker/__init__.py b/gsap_broker/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/gsap_broker/app.py b/gsap_broker/app.py new file mode 100644 index 0000000..c4e56dc --- /dev/null +++ b/gsap_broker/app.py @@ -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"]) diff --git a/gsap_broker/chronicle/__init__.py b/gsap_broker/chronicle/__init__.py new file mode 100644 index 0000000..a8a2c1b --- /dev/null +++ b/gsap_broker/chronicle/__init__.py @@ -0,0 +1 @@ +from .client import emit diff --git a/gsap_broker/chronicle/client.py b/gsap_broker/chronicle/client.py new file mode 100644 index 0000000..15ec723 --- /dev/null +++ b/gsap_broker/chronicle/client.py @@ -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 "" diff --git a/gsap_broker/db.py b/gsap_broker/db.py new file mode 100644 index 0000000..dc43275 --- /dev/null +++ b/gsap_broker/db.py @@ -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 diff --git a/gsap_broker/db_models.py b/gsap_broker/db_models.py new file mode 100644 index 0000000..11fed28 --- /dev/null +++ b/gsap_broker/db_models.py @@ -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 diff --git a/gsap_broker/drivers/__init__.py b/gsap_broker/drivers/__init__.py new file mode 100644 index 0000000..f55c8d0 --- /dev/null +++ b/gsap_broker/drivers/__init__.py @@ -0,0 +1,3 @@ +from .base import IdentityDriver, AuthResult +from .keycloak import KeycloakDriver +from .registry import DriverRegistry diff --git a/gsap_broker/drivers/base.py b/gsap_broker/drivers/base.py new file mode 100644 index 0000000..66072fb --- /dev/null +++ b/gsap_broker/drivers/base.py @@ -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: ... diff --git a/gsap_broker/drivers/keycloak.py b/gsap_broker/drivers/keycloak.py new file mode 100644 index 0000000..fc92c45 --- /dev/null +++ b/gsap_broker/drivers/keycloak.py @@ -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}") diff --git a/gsap_broker/drivers/registry.py b/gsap_broker/drivers/registry.py new file mode 100644 index 0000000..a6349f5 --- /dev/null +++ b/gsap_broker/drivers/registry.py @@ -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()) diff --git a/gsap_broker/models/__init__.py b/gsap_broker/models/__init__.py new file mode 100644 index 0000000..eb3621c --- /dev/null +++ b/gsap_broker/models/__init__.py @@ -0,0 +1 @@ +from .gsap import * diff --git a/gsap_broker/models/gsap.py b/gsap_broker/models/gsap.py new file mode 100644 index 0000000..d2bc772 --- /dev/null +++ b/gsap_broker/models/gsap.py @@ -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 = "" diff --git a/gsap_broker/routers/__init__.py b/gsap_broker/routers/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/gsap_broker/routers/authorize.py b/gsap_broker/routers/authorize.py new file mode 100644 index 0000000..a841579 --- /dev/null +++ b/gsap_broker/routers/authorize.py @@ -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) diff --git a/gsap_broker/routers/complete.py b/gsap_broker/routers/complete.py new file mode 100644 index 0000000..e5c2344 --- /dev/null +++ b/gsap_broker/routers/complete.py @@ -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) diff --git a/gsap_broker/routers/drivers.py b/gsap_broker/routers/drivers.py new file mode 100644 index 0000000..f776f20 --- /dev/null +++ b/gsap_broker/routers/drivers.py @@ -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"} diff --git a/gsap_broker/routers/elevate.py b/gsap_broker/routers/elevate.py new file mode 100644 index 0000000..63bf99b --- /dev/null +++ b/gsap_broker/routers/elevate.py @@ -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.") diff --git a/gsap_broker/routers/health.py b/gsap_broker/routers/health.py new file mode 100644 index 0000000..ab9a346 --- /dev/null +++ b/gsap_broker/routers/health.py @@ -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"} diff --git a/gsap_broker/routers/session.py b/gsap_broker/routers/session.py new file mode 100644 index 0000000..e08c265 --- /dev/null +++ b/gsap_broker/routers/session.py @@ -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) diff --git a/gsap_broker/settings.py b/gsap_broker/settings.py new file mode 100644 index 0000000..13268aa --- /dev/null +++ b/gsap_broker/settings.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dd0a7f8 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af6f6da --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..822c5fe --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 0000000..b0c6bec --- /dev/null +++ b/tests/test_broker.py @@ -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