state-hub/api/services/decision_writeback.py
tegwick 0b473c1bda
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m3s
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
2026-11-01 04:01:48 +01:00

236 lines
9 KiB
Python

"""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