Add protected headless lifecycle application service
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
f7094f8313
commit
b2a4b863a8
7 changed files with 439 additions and 12 deletions
|
|
@ -90,8 +90,10 @@ class LifecycleRepository:
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
def _append(db, scope, digest, memo, kind, body):
|
||||
def _append(db, scope, digest, memo, kind, body, policy_id=None):
|
||||
event_id = str(uuid.uuid4())
|
||||
if policy_id is not None:
|
||||
body = {**body, "policy_observation_id": policy_id}
|
||||
db.execute("INSERT INTO lifecycle_events "
|
||||
"(id,tenant,memo_id,version,kind,actor,at,body) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(event_id, scope[0], memo.id, memo.version, kind, scope[1], _now(), dumps(body)))
|
||||
|
|
@ -99,12 +101,13 @@ class LifecycleRepository:
|
|||
db.execute("INSERT INTO lifecycle_operations VALUES (?,?,?,?,?,?)", (*scope, digest, event_id))
|
||||
return event_id
|
||||
|
||||
def register(self, context: Attribution, memo: Memo, *, key: str) -> str:
|
||||
def register(self, context: Attribution, memo: Memo, *, key: str, guard=None) -> str:
|
||||
"""Register a complete, unbound version-one memo without creating approval."""
|
||||
if memo.version != 1 or memo.sealed or memo.approval_id is not None:
|
||||
raise ValueError("registration requires an unbound version-one memo")
|
||||
scope, digest = self._request(context, "register", key, memo)
|
||||
with self.store._transaction() as db:
|
||||
policy_id = guard(db) if guard is not None else None
|
||||
replay = self._replay(db, scope, digest)
|
||||
if replay:
|
||||
return replay
|
||||
|
|
@ -113,10 +116,10 @@ class LifecycleRepository:
|
|||
raise Conflict("memo identifier already exists")
|
||||
self.store._save_memo(db, memo)
|
||||
db.execute("INSERT INTO lifecycle_records VALUES (?,?,?)", (memo.id, context.tenant, _now()))
|
||||
return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version})
|
||||
return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version}, policy_id)
|
||||
|
||||
def revise(self, context: Attribution, memo: Memo, *, expected_version: int,
|
||||
summary: str, key: str) -> str:
|
||||
summary: str, key: str, guard=None) -> str:
|
||||
"""Compare-and-append; retain existing Store guards on outstanding entries."""
|
||||
if type(expected_version) is not int or expected_version < 1:
|
||||
raise ValueError("expected version must be a positive integer")
|
||||
|
|
@ -127,6 +130,7 @@ class LifecycleRepository:
|
|||
scope, digest = self._request(context, "revise", key,
|
||||
{"memo": memo, "expected_version": expected_version, "summary": summary})
|
||||
with self.store._transaction() as db:
|
||||
policy_id = guard(db) if guard is not None else None
|
||||
self._record(db, context.tenant, memo.id)
|
||||
replay = self._replay(db, scope, digest)
|
||||
if replay:
|
||||
|
|
@ -137,10 +141,10 @@ class LifecycleRepository:
|
|||
if current.sealed or current.approval_id is not None:
|
||||
raise Conflict("bound memo requires the review integration path")
|
||||
self.store._save_memo(db, memo)
|
||||
return self._append(db, scope, digest, memo, "revised", {"summary": summary})
|
||||
return self._append(db, scope, digest, memo, "revised", {"summary": summary}, policy_id)
|
||||
|
||||
def annotate(self, context: Attribution, memo_id: str, version: int, *, kind: str,
|
||||
body: dict, key: str) -> str:
|
||||
body: dict, key: str, guard=None) -> str:
|
||||
"""Append a source, advisory evaluation or discussion; never a disposition."""
|
||||
self._validate_annotation(kind, body)
|
||||
if type(version) is not int or version < 1:
|
||||
|
|
@ -148,6 +152,7 @@ class LifecycleRepository:
|
|||
scope, digest = self._request(context, kind, key,
|
||||
{"memo_id": memo_id, "version": version, "body": body})
|
||||
with self.store._transaction() as db:
|
||||
policy_id = guard(db) if guard is not None else None
|
||||
self._record(db, context.tenant, memo_id)
|
||||
replay = self._replay(db, scope, digest)
|
||||
if replay:
|
||||
|
|
@ -159,7 +164,7 @@ class LifecycleRepository:
|
|||
"AND memo_id=? AND version=? AND kind='source'",
|
||||
(source, context.tenant, memo_id, version)).fetchone():
|
||||
raise EvidenceUnavailable("evaluation source unavailable for this revision")
|
||||
return self._append(db, scope, digest, memo, kind, body)
|
||||
return self._append(db, scope, digest, memo, kind, body, policy_id)
|
||||
|
||||
@staticmethod
|
||||
def _validate_annotation(kind, body):
|
||||
|
|
|
|||
142
informed_decision/lifecycle_service.py
Normal file
142
informed_decision/lifecycle_service.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""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 time
|
||||
import uuid
|
||||
|
||||
from .disposition import Actor, ActorKind
|
||||
from .lifecycle import Attribution, LifecycleRepository
|
||||
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
|
||||
|
||||
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")
|
||||
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": "memo:" + memo_id, "type": "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"),
|
||||
"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")
|
||||
return policy_id
|
||||
|
||||
current()
|
||||
return current, guard
|
||||
|
||||
def register(self, caller, memo, *, key):
|
||||
# Snapshot before the PDP call: mutable caller-owned objects cannot change
|
||||
# the command between authorization and persistence.
|
||||
memo = memo_from(json.loads(dumps(memo)))
|
||||
_, guard = self._authorize(caller, memo.id, "register", {"memo": memo, "key": key})
|
||||
return self.repository.register(caller.attribution, memo, key=key, guard=guard)
|
||||
|
||||
def revise(self, caller, memo, *, expected_version, summary, key):
|
||||
memo = memo_from(json.loads(dumps(memo)))
|
||||
_, guard = self._authorize(caller, memo.id, "revise", {
|
||||
"memo": memo, "expected_version": expected_version, "summary": summary, "key": key})
|
||||
return self.repository.revise(caller.attribution, memo, expected_version=expected_version,
|
||||
summary=summary, key=key, guard=guard)
|
||||
|
||||
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 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue