STATE-WP-0101-T02/T03: decision classes, evidence refusals, write-back route
T02: additive migration d5e6f7a8b9c0 (decision_class, memo_id,
approval_id, resolution_source, resolution_evidence, evidence_refs,
CHECKs, partial unique memo_id); typed evidence_refs; 409
evidence_required for text resolve, resolving PATCH fields and resolved
create on C1/C2; raise-only class; C3 refused as reserved.
T03: POST /decisions/{id}/resolve-evidence (link/resolve), projected
token verified by TokenReview with audience
state-hub-decision-writeback and an exact subject allowlist; outcome
record v1 validation, 8 KiB bound, secret scan; idempotent on
(decision_id, memo_id). Off by default (DECISION_WRITEBACK_ENABLED).
Tests use a fake reviewer; full suite 1286 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
parent
b86bd17cef
commit
0b473c1bda
8 changed files with 1225 additions and 39 deletions
236
api/services/decision_writeback.py
Normal file
236
api/services/decision_writeback.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Authenticated decision write-back (STATE-WP-0101-T03).
|
||||
|
||||
Design: docs/decision-services-alignment-answer.md sections 5.2 and 5.3; founder
|
||||
decisions 1 and 2 of 2026-11-01 (section 8.1). The dispatch worker pulls decided
|
||||
outcomes from informed-decision and posts them here. Exactly one caller is
|
||||
admitted: a Kubernetes projected ServiceAccount token with the dedicated
|
||||
audience, verified by TokenReview, whose subject is on an exact allowlist.
|
||||
|
||||
The hub records the human subject as attested by informed-decision through the
|
||||
authenticated worker; it does not authenticate the human itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from api.config import settings
|
||||
from api.services.informed_decision_contract import ContractError, scan_secrets
|
||||
|
||||
OUTCOME_KIND = "informed-decision.decision-outcome"
|
||||
MAX_OUTCOME_BYTES = 8192
|
||||
MAX_NOTE_BYTES = 2048
|
||||
|
||||
_MEMO_ID_PATTERN = r"^[a-z0-9][a-z0-9-]{2,62}$"
|
||||
_VIEW_HASH_PATTERN = r"^sha256:[0-9a-f]{64}$"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outcome record, informed-decision.decision-outcome v1 (design section 5.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OutcomeSubject(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
iss: str = Field(min_length=1, max_length=512)
|
||||
sub: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class OutcomeAssurance(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
acr: str | None = Field(default=None, max_length=128)
|
||||
aal: str | None = Field(default=None, max_length=32)
|
||||
auth_time: datetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _level_present(self) -> "OutcomeAssurance":
|
||||
if not self.acr and not self.aal:
|
||||
raise ValueError("assurance needs acr or aal")
|
||||
if self.auth_time.tzinfo is None:
|
||||
raise ValueError("auth_time must carry a timezone")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionOutcome(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
kind: Literal["informed-decision.decision-outcome"]
|
||||
version: Literal[1]
|
||||
hub_decision_id: uuid.UUID
|
||||
memo_id: str = Field(pattern=_MEMO_ID_PATTERN)
|
||||
memo_revision: int = Field(ge=0)
|
||||
verb: Literal["accept", "decline"]
|
||||
option: str | None = Field(default=None, max_length=64)
|
||||
subject: OutcomeSubject
|
||||
assurance: OutcomeAssurance
|
||||
decided_at: datetime
|
||||
view_hash: str = Field(pattern=_VIEW_HASH_PATTERN)
|
||||
presentation_id: str = Field(min_length=1, max_length=128)
|
||||
approval_id: uuid.UUID | None = None
|
||||
approval_entry_id: str | None = Field(default=None, max_length=128)
|
||||
audit_ref: str | None = Field(default=None, max_length=256)
|
||||
display_name: str | None = Field(default=None, max_length=100)
|
||||
note: str | None = None
|
||||
|
||||
@field_validator("decided_at")
|
||||
@classmethod
|
||||
def _utc(cls, value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
raise ValueError("decided_at must carry a timezone")
|
||||
return value
|
||||
|
||||
@field_validator("note")
|
||||
@classmethod
|
||||
def _bounded_note(cls, value: str | None) -> str | None:
|
||||
if value is not None and len(value.encode("utf-8")) > MAX_NOTE_BYTES:
|
||||
raise ValueError(f"note exceeds {MAX_NOTE_BYTES} bytes")
|
||||
return value
|
||||
|
||||
|
||||
class DecisionEvidenceWrite(BaseModel):
|
||||
"""Body of POST /decisions/{id}/resolve-evidence."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
mode: Literal["link", "resolve"]
|
||||
memo_id: str = Field(pattern=_MEMO_ID_PATTERN)
|
||||
approval_id: uuid.UUID | None = None
|
||||
outcome: DecisionOutcome | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _mode_shape(self) -> "DecisionEvidenceWrite":
|
||||
if self.mode == "resolve" and self.outcome is None:
|
||||
raise ValueError("resolve mode requires an outcome record")
|
||||
if self.mode == "link" and self.outcome is not None:
|
||||
raise ValueError("link mode carries no outcome record")
|
||||
return self
|
||||
|
||||
|
||||
def stored_outcome(outcome: DecisionOutcome) -> dict:
|
||||
"""The record as received (unset optional fields omitted), bounded and secret-scanned."""
|
||||
record = outcome.model_dump(mode="json", exclude_unset=True)
|
||||
encoded = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
if len(encoded.encode("utf-8")) > MAX_OUTCOME_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"error": "outcome_too_large", "limit_bytes": MAX_OUTCOME_BYTES},
|
||||
)
|
||||
try:
|
||||
scan_secrets(record)
|
||||
except ContractError:
|
||||
# The matched text is never echoed.
|
||||
raise HTTPException(status_code=422, detail={"error": "secret_marker"}) from None
|
||||
return record
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caller verification (design section 5.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenReviewResult:
|
||||
authenticated: bool
|
||||
username: str | None = None
|
||||
audiences: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class TokenReviewer(Protocol):
|
||||
async def review(self, token: str, audience: str) -> TokenReviewResult: ...
|
||||
|
||||
|
||||
class TokenReviewUnavailable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class KubernetesTokenReviewer:
|
||||
"""TokenReview against the cluster API with the API pod's own ServiceAccount.
|
||||
|
||||
Needs the API ServiceAccount bound to ``system:auth-delegator`` (founder-run
|
||||
cluster RBAC). Any transport or API failure raises TokenReviewUnavailable;
|
||||
the route then refuses (fail closed).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str | None = None,
|
||||
serviceaccount_dir: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self.api_url = (api_url or settings.kubernetes_api_url).rstrip("/")
|
||||
self.sa_dir = Path(serviceaccount_dir or settings.kubernetes_serviceaccount_dir)
|
||||
self.timeout = timeout if timeout is not None else settings.token_review_timeout_seconds
|
||||
|
||||
async def review(self, token: str, audience: str) -> TokenReviewResult:
|
||||
try:
|
||||
own_token = (self.sa_dir / "token").read_text().strip()
|
||||
ca = str(self.sa_dir / "ca.crt")
|
||||
async with httpx.AsyncClient(verify=ca, timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/apis/authentication.k8s.io/v1/tokenreviews",
|
||||
headers={"Authorization": f"Bearer {own_token}"},
|
||||
json={
|
||||
"apiVersion": "authentication.k8s.io/v1",
|
||||
"kind": "TokenReview",
|
||||
"spec": {"token": token, "audiences": [audience]},
|
||||
},
|
||||
)
|
||||
except (OSError, httpx.HTTPError) as exc:
|
||||
raise TokenReviewUnavailable(type(exc).__name__) from None
|
||||
if response.status_code != 201 and response.status_code != 200:
|
||||
raise TokenReviewUnavailable(f"tokenreview_http_{response.status_code}")
|
||||
status = (response.json() or {}).get("status") or {}
|
||||
return TokenReviewResult(
|
||||
authenticated=bool(status.get("authenticated")),
|
||||
username=(status.get("user") or {}).get("username"),
|
||||
audiences=tuple(status.get("audiences") or ()),
|
||||
)
|
||||
|
||||
|
||||
def get_token_reviewer() -> TokenReviewer:
|
||||
"""FastAPI dependency; tests override it with a fake reviewer."""
|
||||
return KubernetesTokenReviewer()
|
||||
|
||||
|
||||
def allowed_subjects() -> frozenset[str]:
|
||||
return frozenset(
|
||||
s.strip() for s in settings.decision_writeback_subjects.split(",") if s.strip()
|
||||
)
|
||||
|
||||
|
||||
async def require_writeback_caller(
|
||||
request: Request, reviewer: TokenReviewer = Depends(get_token_reviewer)
|
||||
) -> str:
|
||||
"""Return the verified caller subject or refuse. Feature flag first, then token."""
|
||||
if not settings.decision_writeback_enabled:
|
||||
raise HTTPException(status_code=503, detail={"error": "decision_writeback_disabled"})
|
||||
header = request.headers.get("authorization", "")
|
||||
scheme, _, token = header.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token.strip():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "token_required"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
audience = settings.decision_writeback_audience
|
||||
try:
|
||||
result = await reviewer.review(token.strip(), audience)
|
||||
except TokenReviewUnavailable:
|
||||
raise HTTPException(
|
||||
status_code=503, detail={"error": "token_review_unavailable"}
|
||||
) from None
|
||||
if not result.authenticated or audience not in result.audiences:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "token_invalid"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if result.username not in allowed_subjects():
|
||||
raise HTTPException(status_code=403, detail={"error": "subject_not_allowed"})
|
||||
return result.username
|
||||
Loading…
Add table
Add a link
Reference in a new issue