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