From 295fe55bf2a4be6c5db0a814b3078ae2959e224756d971c34e68758ce0d6dad2 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Fri, 3 Apr 2026 02:06:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20session-scoped=20ACs=20=E2=80=94=20mult?= =?UTF-8?q?iple=20CRs=20per=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds session_mode flag to AC lifecycle. When session_mode=true: - AC transitions to 'active' (not 'consumed') on first CR - Stays active for subsequent CRs during the session - 'session_end' outcome transitions AC to 'consumed' - Non-session ACs behave as before (consumed on first CR) Schema: - ACStatus: add ACTIVE enum value - Outcome: add SESSION_END enum value - AuthorizeRequest: add session_mode bool field - AuthorizationContextDB: add session_mode column - Auto-migration via ALTER TABLE on startup Co-Authored-By: Claude Opus 4.6 (1M context) --- gsap_broker/db.py | 7 +++++++ gsap_broker/db_models.py | 1 + gsap_broker/models/gsap.py | 3 +++ gsap_broker/routers/authorize.py | 3 ++- gsap_broker/routers/complete.py | 22 ++++++++++++++++------ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/gsap_broker/db.py b/gsap_broker/db.py index dc43275..148d6ab 100644 --- a/gsap_broker/db.py +++ b/gsap_broker/db.py @@ -8,6 +8,13 @@ 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) + # Schema migrations for existing DBs: + try: + await conn.execute( + __import__("sqlalchemy").text("ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0") + ) + except Exception: + pass # Column already exists async def get_session(): async with AsyncSession(engine) as session: diff --git a/gsap_broker/db_models.py b/gsap_broker/db_models.py index 11fed28..62d43cc 100644 --- a/gsap_broker/db_models.py +++ b/gsap_broker/db_models.py @@ -22,6 +22,7 @@ class AuthorizationContextDB(SQLModel, table=True): issued_at: datetime = Field(default_factory=datetime.utcnow) expires_at: datetime consumed_at: Optional[datetime] = None + session_mode: bool = False poll_token: Optional[str] = None chronicle_event_cid: str = "" diff --git a/gsap_broker/models/gsap.py b/gsap_broker/models/gsap.py index d2bc772..82a61e9 100644 --- a/gsap_broker/models/gsap.py +++ b/gsap_broker/models/gsap.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field class ACStatus(str, Enum): PENDING = "pending" AUTHORIZED = "authorized" + ACTIVE = "active" # Session-scoped: accepts multiple CRs until session_end CONSUMED = "consumed" EXPIRED = "expired" REVOKED = "revoked" @@ -17,6 +18,7 @@ class Outcome(str, Enum): FAILED = "failed" VIOLATED = "violated" TIMED_OUT = "timed_out" + SESSION_END = "session_end" # Final CR for session-scoped ACs → transitions to consumed class BehaviorStatus(str, Enum): VERIFIED = "verified" @@ -73,6 +75,7 @@ class AuthorizeRequest(BaseModel): parameters_cid: str accord_template: str driver_id: str + session_mode: bool = False # When true, AC stays active across multiple CRs class AuthorizeResponse(BaseModel): status: str diff --git a/gsap_broker/routers/authorize.py b/gsap_broker/routers/authorize.py index 8906060..1b1fb7b 100644 --- a/gsap_broker/routers/authorize.py +++ b/gsap_broker/routers/authorize.py @@ -80,7 +80,8 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess 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) + mfa_satisfied=auth_result.mfa_satisfied, status="authorized", issued_at=now, expires_at=expires, + session_mode=request.session_mode) 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 diff --git a/gsap_broker/routers/complete.py b/gsap_broker/routers/complete.py index b2bb47a..79fe1e6 100644 --- a/gsap_broker/routers/complete.py +++ b/gsap_broker/routers/complete.py @@ -13,14 +13,18 @@ router = APIRouter() @router.post("/complete/", response_model=CompleteResponse, summary="Receive CR (GSAP §5.4)") async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_session)): # Pure raw SQL to avoid all SQLModel/SQLAlchemy greenlet issues + ctx_id_str = str(request.context_id).replace("-", "") result = await db.execute( - text("SELECT context_id, status FROM authorization_contexts WHERE context_id = :ctx_id AND status = 'authorized'"), - {"ctx_id": str(request.context_id).replace("-", "")}, + text("SELECT context_id, status, session_mode FROM authorization_contexts WHERE context_id = :ctx_id AND status IN ('authorized', 'active')"), + {"ctx_id": ctx_id_str}, ) ac_row = result.first() if not ac_row: raise HTTPException(status_code=404, detail="AC not found or already consumed.") + ac_status = ac_row[1] + is_session = bool(ac_row[2]) if len(ac_row) > 2 else False + sig_verified = bool((request.signature or {}).get("value")) cr_id = uuid.uuid4() now = datetime.now(UTC) @@ -37,7 +41,7 @@ async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_sess :ba_status, :ffc_did, :ffc_sig, :sig_verified, '')"""), { - "id": str(cr_id).replace("-", ""), "ctx_id": str(request.context_id).replace("-", ""), + "id": str(cr_id).replace("-", ""), "ctx_id": ctx_id_str, "outcome": request.outcome.value, "completed_at": str(request.completed_at), "received_at": str(now), @@ -52,10 +56,16 @@ async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_sess }, ) - # Update AC status + # Update AC status based on session mode: + # - Session mode + non-final outcome → 'active' (stays open for more CRs) + # - Session mode + session_end outcome → 'consumed' (session finished) + # - Non-session mode → 'consumed' (single-use, original behavior) + is_final = request.outcome.value == "session_end" or not is_session + new_status = "consumed" if is_final else "active" + await db.execute( - text("UPDATE authorization_contexts SET status = 'consumed', consumed_at = :now WHERE context_id = :ctx_id"), - {"now": str(now), "ctx_id": str(request.context_id).replace("-", "")}, + text("UPDATE authorization_contexts SET status = :status, consumed_at = :now WHERE context_id = :ctx_id"), + {"status": new_status, "now": str(now) if is_final else None, "ctx_id": ctx_id_str}, ) # Chronicle event