informed-decision/informed_decision/lifecycle_service.py
tegwick f3b71b1521 Connect lifecycle revisions to protected human review
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
2026-10-04 20:33:24 +02:00

227 lines
12 KiB
Python

"""Headless application service consuming fresh, exact external policy decisions.
Identity adapters supply Caller after authentication; no request body can mint
one. Runtime composition and lifecycle policy admission remain separate work.
"""
from dataclasses import dataclass
import hashlib
import json
import math
import secrets
import threading
import time
import uuid
from .disposition import Actor, ActorKind
from .lifecycle import Attribution, LifecycleRepository, publication_audience
from .oidc import HumanSession
from .policy import PolicyError
from .provenance import Route
from .records import dumps, memo_from
from .store import _text
@dataclass(frozen=True)
class Caller:
attribution: Attribution
expires_at: float
attributes_json: str
def __post_init__(self):
if (type(self.expires_at) not in (int, float) or not math.isfinite(self.expires_at)
or not isinstance(self.attributes_json, str) or len(self.attributes_json) > 16384):
raise ValueError("invalid authenticated caller")
attributes = json.loads(self.attributes_json)
if not isinstance(attributes, dict):
raise ValueError("invalid caller attributes")
@classmethod
def from_human_session(cls, session: HumanSession):
if (not isinstance(session, HumanSession)
or session.principal_type.value != "human"
or session.principal_type.route is not Route.AUTHENTICATION
or session.tenant.route not in (Route.DIRECTORY, Route.REGISTRATION)):
raise ValueError("verified human identity required")
return cls(Attribution(session.tenant.value, Actor(session.subject, ActorKind.PERSON)),
session.expires_at, dumps({
"tenant_source": session.tenant.route.value,
"principal_type_source": session.principal_type.route.value,
"assurance": session.assurance,
"roles": list(session.roles), "groups": list(session.groups)}))
class LifecycleError(RuntimeError):
def __init__(self, status, code):
self.status, self.code = status, code
super().__init__(code)
class LifecycleService:
def __init__(self, repository: LifecycleRepository, policy, *, clock=time.time):
self.repository, self.policy, self.clock = repository, policy, clock
self._cursors = {}
self._cursor_lock = threading.Lock()
def _caller(self, caller):
if not isinstance(caller, Caller) or caller.expires_at <= self.clock():
raise LifecycleError(401, "identity_expired")
def _authorize(self, caller, memo_id, operation, payload):
self._caller(caller)
_text(memo_id, "memo id")
actor = caller.attribution.actor
tenant = caller.attribution.tenant
command = dumps(payload).encode()
if len(command) > 256 * 1024:
raise ValueError("lifecycle command too large")
version = payload.get("expected_version", payload.get("memo").version
if payload.get("memo") is not None else payload.get("version"))
state = (self.repository.policy_context(tenant, memo_id, version)
if operation != "search" else {"registered": False})
request = {"id": str(uuid.uuid4()), "tenant": tenant,
"subject": {"id": actor.sub, "type": "human" if actor.kind is ActorKind.PERSON else actor.kind.value,
"tenant": tenant, "attributes": json.loads(caller.attributes_json)},
"action": "lifecycle." + operation,
"resource": {"id": "decisions" if operation == "search" else "memo:" + memo_id,
"type": "decision-collection" if operation == "search" else "decision-memo",
"system": "informed-decision", "tenant": tenant},
"context": {"contract": "informed-decision.lifecycle.v1",
"memo_version": payload.get("memo").version if payload.get("memo") is not None else payload.get("version"),
"record": state,
**({"publication_audience": payload["audience"]} if operation == "publish" else {}),
**({"review_routing": payload["options"]} if operation == "request_review" else {}),
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
"policy_version": self.policy.version}
observation = self.policy.check(request)
# Defend the application boundary even with an incorrectly composed client.
if json.loads(observation.request_json) != request:
raise LifecycleError(503, "policy_request_mismatch")
policy_id = self.repository.store.observe_policy(observation)
def current():
self._caller(caller)
try:
observation.require_current(self.clock())
except PolicyError:
raise LifecycleError(403 if observation.outcome == "policy_denied" else 503,
observation.outcome if observation.outcome != "allow" else "decision_expired") from None
def guard(db):
current()
recorded = db.execute("SELECT request,outcome,usable_until FROM policy_observations WHERE id=?",
(policy_id,)).fetchone()
if (recorded is None or recorded["request"] != observation.request_json
or recorded["outcome"] != "allow" or recorded["usable_until"] <= self.clock()):
raise LifecycleError(503, "policy_observation_unavailable")
if self.repository._policy_context(db, tenant, memo_id, version) != state:
raise LifecycleError(409, "record_changed")
return policy_id
def read_current():
current()
if operation != "search" and self.repository.policy_context(tenant, memo_id, version) != state:
raise LifecycleError(409, "record_changed")
current()
return read_current, guard
def register(self, caller, memo, *, key, documents=None):
# Snapshot before the PDP call: mutable caller-owned objects cannot change
# the command between authorization and persistence.
memo = memo_from(json.loads(dumps(memo)))
documents = json.loads(dumps([] if documents is None else documents))
_, guard = self._authorize(caller, memo.id, "register", {"memo": memo, "key": key, "documents": documents})
return self.repository.register(caller.attribution, memo, key=key, guard=guard, documents=documents)
def revise(self, caller, memo, *, expected_version, summary, key, documents=None):
memo = memo_from(json.loads(dumps(memo)))
documents = json.loads(dumps([] if documents is None else documents))
_, guard = self._authorize(caller, memo.id, "revise", {
"memo": memo, "expected_version": expected_version, "summary": summary, "key": key, "documents": documents})
return self.repository.revise(caller.attribution, memo, expected_version=expected_version,
summary=summary, key=key, guard=guard, documents=documents)
def annotate(self, caller, memo_id, version, *, kind, body, key):
body = json.loads(dumps(body))
self.repository._validate_annotation(kind, body)
_, guard = self._authorize(caller, memo_id, kind, {
"memo_id": memo_id, "version": version, "body": body, "key": key})
return self.repository.annotate(caller.attribution, memo_id, version, kind=kind,
body=body, key=key, guard=guard)
def retrieve(self, caller, memo_id, version=None):
current, _ = self._authorize(caller, memo_id, "read", {"version": version})
result = self.repository.retrieve(caller.attribution.tenant, memo_id, version)
current()
return result
def publish(self, caller, memo_id, version, *, audience, key):
audience = publication_audience(audience)
_, guard = self._authorize(caller, memo_id, "publish",
{"version": version, "audience": audience, "key": key})
return self.repository.publish(caller.attribution, memo_id, version, audience=audience, key=key, guard=guard)
def packet(self, caller, memo_id, version, index):
current, _ = self._authorize(caller, memo_id, "packet", {"version": version, "index": index})
result = self.repository.packet(caller.attribution.tenant, memo_id, version, index)
current()
return result
def request_review(self, caller, memo_id, version, *, options, key):
from .lifecycle_review import prepare
options = json.loads(dumps(options))
_, guard = self._authorize(caller, memo_id, "request_review",
{"version": version, "options": options, "key": key})
return prepare(self.repository, caller.attribution, memo_id, version, options, key=key, guard=guard)
def outcomes(self, caller, memo_id, version):
from .lifecycle_review import outcomes
current, _ = self._authorize(caller, memo_id, "outcomes", {"version": version})
result = outcomes(self.repository, caller.attribution.tenant, memo_id, version)
current()
return result
def search(self, caller, *, query="", cursor=None):
self._caller(caller)
if not isinstance(query, str) or len(query) > 200:
raise ValueError("invalid search query")
scope = (caller.attribution.tenant, dumps(caller.attribution.actor), query)
after = ""
with self._cursor_lock:
self._cursors = {key: row for key, row in self._cursors.items() if row[2] > self.clock()}
if cursor is not None:
row = self._cursors.get(cursor) if isinstance(cursor, str) else None
if row is None or row[0] != scope:
raise ValueError("invalid search cursor")
after = row[1]
current, _ = self._authorize(caller, "decisions", "search", {"query": query, "after": after})
candidates = self.repository.candidates(caller.attribution.tenant, query=query, after=after, limit=50)
results, checks = [], [current]
for memo_id in candidates:
try:
check, _ = self._authorize(caller, memo_id, "read", {"version": None})
except LifecycleError as error:
if error.status == 403:
continue
raise
memo = self.repository.retrieve(caller.attribution.tenant, memo_id)
check()
checks.append(check)
results.append({"memo_id": memo.id, "version": memo.version, "question": memo.question})
for check in checks:
check()
next_cursor = None
if len(candidates) == 50:
with self._cursor_lock:
if len(self._cursors) >= 1000:
raise LifecycleError(503, "search_capacity")
next_cursor = secrets.token_urlsafe(24)
self._cursors[next_cursor] = (scope, candidates[-1], self.clock() + 300)
return {"decisions": results, "next_cursor": next_cursor}
def events(self, caller, memo_id, *, after=0, limit=100):
current, _ = self._authorize(caller, memo_id, "events", {"after": after, "limit": limit})
result = self.repository.events(caller.attribution.tenant, memo_id, after=after, limit=limit)
current()
return result