Implement lifecycle workspace publication and audit delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
ee472acc87
commit
5fec7de4cb
21 changed files with 1022 additions and 78 deletions
|
|
@ -8,11 +8,13 @@ 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
|
||||
from .lifecycle import Attribution, LifecycleRepository, publication_audience
|
||||
from .oidc import HumanSession
|
||||
from .policy import PolicyError
|
||||
from .provenance import Route
|
||||
|
|
@ -58,6 +60,8 @@ class LifecycleError(RuntimeError):
|
|||
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():
|
||||
|
|
@ -71,14 +75,21 @@ class LifecycleService:
|
|||
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": "memo:" + memo_id, "type": "decision-memo",
|
||||
"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 {}),
|
||||
"command_digest": "sha256:" + hashlib.sha256(command).hexdigest()},
|
||||
"policy_version": self.policy.version}
|
||||
observation = self.policy.check(request)
|
||||
|
|
@ -102,10 +113,17 @@ class LifecycleService:
|
|||
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 current, guard
|
||||
return read_current, guard
|
||||
|
||||
def register(self, caller, memo, *, key, documents=None):
|
||||
# Snapshot before the PDP call: mutable caller-owned objects cannot change
|
||||
|
|
@ -137,6 +155,56 @@ class LifecycleService:
|
|||
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 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue