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:
parent
5ac577af19
commit
295fe55bf2
5 changed files with 29 additions and 7 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue