feat: session-scoped ACs — multiple CRs per session

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) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-04-03 02:06:15 -04:00
parent 5ac577af19
commit 295fe55bf2
5 changed files with 29 additions and 7 deletions

View file

@ -8,6 +8,13 @@ engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
async def init_db(): async def init_db():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all) 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 def get_session():
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:

View file

@ -22,6 +22,7 @@ class AuthorizationContextDB(SQLModel, table=True):
issued_at: datetime = Field(default_factory=datetime.utcnow) issued_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime expires_at: datetime
consumed_at: Optional[datetime] = None consumed_at: Optional[datetime] = None
session_mode: bool = False
poll_token: Optional[str] = None poll_token: Optional[str] = None
chronicle_event_cid: str = "" chronicle_event_cid: str = ""

View file

@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
class ACStatus(str, Enum): class ACStatus(str, Enum):
PENDING = "pending" PENDING = "pending"
AUTHORIZED = "authorized" AUTHORIZED = "authorized"
ACTIVE = "active" # Session-scoped: accepts multiple CRs until session_end
CONSUMED = "consumed" CONSUMED = "consumed"
EXPIRED = "expired" EXPIRED = "expired"
REVOKED = "revoked" REVOKED = "revoked"
@ -17,6 +18,7 @@ class Outcome(str, Enum):
FAILED = "failed" FAILED = "failed"
VIOLATED = "violated" VIOLATED = "violated"
TIMED_OUT = "timed_out" TIMED_OUT = "timed_out"
SESSION_END = "session_end" # Final CR for session-scoped ACs → transitions to consumed
class BehaviorStatus(str, Enum): class BehaviorStatus(str, Enum):
VERIFIED = "verified" VERIFIED = "verified"
@ -73,6 +75,7 @@ class AuthorizeRequest(BaseModel):
parameters_cid: str parameters_cid: str
accord_template: str accord_template: str
driver_id: str driver_id: str
session_mode: bool = False # When true, AC stays active across multiple CRs
class AuthorizeResponse(BaseModel): class AuthorizeResponse(BaseModel):
status: str status: str

View file

@ -80,7 +80,8 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid, playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid,
parameters_cid=request.parameters_cid, accord_template=request.accord_template, parameters_cid=request.parameters_cid, accord_template=request.accord_template,
token_jti=auth_result.token_jti, elevation_active=auth_result.elevation_active, 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) 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}) 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 ac_db.chronicle_event_cid = cid

View file

@ -13,14 +13,18 @@ router = APIRouter()
@router.post("/complete/", response_model=CompleteResponse, summary="Receive CR (GSAP §5.4)") @router.post("/complete/", response_model=CompleteResponse, summary="Receive CR (GSAP §5.4)")
async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_session)): async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_session)):
# Pure raw SQL to avoid all SQLModel/SQLAlchemy greenlet issues # Pure raw SQL to avoid all SQLModel/SQLAlchemy greenlet issues
ctx_id_str = str(request.context_id).replace("-", "")
result = await db.execute( result = await db.execute(
text("SELECT context_id, status FROM authorization_contexts WHERE context_id = :ctx_id AND status = 'authorized'"), text("SELECT context_id, status, session_mode FROM authorization_contexts WHERE context_id = :ctx_id AND status IN ('authorized', 'active')"),
{"ctx_id": str(request.context_id).replace("-", "")}, {"ctx_id": ctx_id_str},
) )
ac_row = result.first() ac_row = result.first()
if not ac_row: if not ac_row:
raise HTTPException(status_code=404, detail="AC not found or already consumed.") 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")) sig_verified = bool((request.signature or {}).get("value"))
cr_id = uuid.uuid4() cr_id = uuid.uuid4()
now = datetime.now(UTC) now = datetime.now(UTC)
@ -37,7 +41,7 @@ async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_sess
:ba_status, :ffc_did, :ffc_sig, :ba_status, :ffc_did, :ffc_sig,
:sig_verified, '')"""), :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, "outcome": request.outcome.value,
"completed_at": str(request.completed_at), "completed_at": str(request.completed_at),
"received_at": str(now), "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( await db.execute(
text("UPDATE authorization_contexts SET status = 'consumed', consumed_at = :now WHERE context_id = :ctx_id"), text("UPDATE authorization_contexts SET status = :status, consumed_at = :now WHERE context_id = :ctx_id"),
{"now": str(now), "ctx_id": str(request.context_id).replace("-", "")}, {"status": new_status, "now": str(now) if is_final else None, "ctx_id": ctx_id_str},
) )
# Chronicle event # Chronicle event