177 lines
8.4 KiB
Python
177 lines
8.4 KiB
Python
|
|
"""Protected review orchestration. Policy comes only from the injected PDP client."""
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
import time
|
||
|
|
|
||
|
|
from .approval_client import ApprovalEngineError
|
||
|
|
from .disposition import Actor, ActorKind, Verb
|
||
|
|
from .memo import BindingLevel, StepKind
|
||
|
|
from .oidc import HumanSession
|
||
|
|
from .policy import PolicyError, build_request
|
||
|
|
from .provenance import Route, assert_human_control_dischargeable
|
||
|
|
from .store import Conflict
|
||
|
|
|
||
|
|
UI_RELEASE = "informed-decision@0.2.0"
|
||
|
|
|
||
|
|
|
||
|
|
class ReviewError(RuntimeError):
|
||
|
|
def __init__(self, status, code):
|
||
|
|
super().__init__(code)
|
||
|
|
self.status, self.code = status, code
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class ReviewPage:
|
||
|
|
memo: object
|
||
|
|
presentation: object
|
||
|
|
binding: dict
|
||
|
|
documents: dict
|
||
|
|
intent: dict | None
|
||
|
|
stale: bool
|
||
|
|
engine_status: str
|
||
|
|
dispositions: tuple = ()
|
||
|
|
|
||
|
|
|
||
|
|
class ReviewController:
|
||
|
|
def __init__(self, store, policy, approval_factory, *, clock=time.time):
|
||
|
|
self.store, self.policy, self.approval_factory = store, policy, approval_factory
|
||
|
|
self.clock = clock
|
||
|
|
|
||
|
|
def _session(self, session):
|
||
|
|
if not isinstance(session, HumanSession) or session.expires_at <= self.clock():
|
||
|
|
raise ReviewError(401, "session_expired")
|
||
|
|
assert_human_control_dischargeable(session.principal_type)
|
||
|
|
if session.tenant.value != "tenant:platform" or session.tenant.route not in (Route.DIRECTORY, Route.REGISTRATION):
|
||
|
|
raise ReviewError(403, "wrong_identity")
|
||
|
|
|
||
|
|
def _memo(self, session, memo):
|
||
|
|
self._session(session)
|
||
|
|
# Stage 1 is a named approver, not a mandate discovery mechanism. This
|
||
|
|
# structural match never grants entitlement; a PDP allow is still owed.
|
||
|
|
if memo.binding.principal.id != session.subject or memo.binding.principal.kind != "person":
|
||
|
|
raise ReviewError(403, "wrong_recipient")
|
||
|
|
if not memo.approval_id or not memo.approval_binding_digest:
|
||
|
|
raise ReviewError(409, "missing_act_binding")
|
||
|
|
if memo.ui_release != UI_RELEASE:
|
||
|
|
raise ReviewError(409, "renderer_changed")
|
||
|
|
if (memo.locale != "en" or memo.binding_level is not BindingLevel.ORGANIZATIONAL
|
||
|
|
or memo.step_kind is not StepKind.APPROVE):
|
||
|
|
raise ReviewError(409, "unsupported_review_profile")
|
||
|
|
|
||
|
|
def _authorize(self, session, memo, action):
|
||
|
|
self._memo(session, memo)
|
||
|
|
request = build_request(session, memo, action, self.policy.version)
|
||
|
|
observation = self.policy.check(request)
|
||
|
|
policy_id = self.store.observe_policy(observation)
|
||
|
|
try:
|
||
|
|
observation.require_current(self.clock())
|
||
|
|
except PolicyError:
|
||
|
|
if observation.outcome == "policy_unavailable":
|
||
|
|
self.store.record_unreachable(memo.id, "access-engine")
|
||
|
|
raise ReviewError(403 if observation.outcome == "policy_denied" else 503, observation.outcome) from None
|
||
|
|
self._session(session)
|
||
|
|
return policy_id, observation
|
||
|
|
|
||
|
|
def _approval(self, session, memo):
|
||
|
|
current = self.approval_factory(session).get_approval(memo.approval_id)
|
||
|
|
if current["binding"]["digest"] != memo.approval_binding_digest:
|
||
|
|
raise ReviewError(409, "binding_changed")
|
||
|
|
return current
|
||
|
|
|
||
|
|
def open(self, session, memo_id):
|
||
|
|
self._session(session)
|
||
|
|
memo = self.store.memo(memo_id)
|
||
|
|
policy_id, observation = self._authorize(session, memo, "read")
|
||
|
|
current = self._approval(session, memo)
|
||
|
|
observation.require_current(self.clock())
|
||
|
|
self._session(session)
|
||
|
|
intent = self.store.intent_for(memo.approval_id, session.subject)
|
||
|
|
if intent is not None:
|
||
|
|
# Recover the original view instead of assigning an existing entry
|
||
|
|
# or uncertain attempt to a freshly rendered presentation.
|
||
|
|
return self.load(session, intent["presentation_id"])
|
||
|
|
p = self.store.present(memo.id, principal_sub=session.subject, tenant=session.tenant,
|
||
|
|
principal_type=session.principal_type, expected_version=memo.version,
|
||
|
|
policy_id=policy_id, approval_binding=current["binding"])
|
||
|
|
saved, p, docs = self.store.retrieve_presentation(p.id)
|
||
|
|
return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"])
|
||
|
|
|
||
|
|
def _presentation(self, session, presentation_id, *, current=False):
|
||
|
|
self._session(session)
|
||
|
|
p = self.store.presentation(presentation_id)
|
||
|
|
if p.principal_sub != session.subject:
|
||
|
|
raise ReviewError(403, "wrong_recipient")
|
||
|
|
memo = self.store.memo(p.memo_id, p.memo_version)
|
||
|
|
self._memo(session, memo)
|
||
|
|
if current and self.store.memo(memo.id).version != memo.version:
|
||
|
|
raise ReviewError(409, "stale_presentation")
|
||
|
|
return memo, p
|
||
|
|
|
||
|
|
def load(self, session, presentation_id):
|
||
|
|
memo, p = self._presentation(session, presentation_id)
|
||
|
|
_, observation = self._authorize(session, memo, "read")
|
||
|
|
current = self._approval(session, memo)
|
||
|
|
content = self.store.presentation_content(p.id)
|
||
|
|
binding = content.get("approval_binding")
|
||
|
|
if not isinstance(binding, dict) or binding.get("digest") != memo.approval_binding_digest:
|
||
|
|
raise ReviewError(409, "missing_act_binding")
|
||
|
|
observation.require_current(self.clock())
|
||
|
|
self._session(session)
|
||
|
|
saved, p, docs = self.store.retrieve_presentation(p.id)
|
||
|
|
return ReviewPage(saved, p, binding, docs,
|
||
|
|
self.store.intent_for(memo.approval_id, session.subject),
|
||
|
|
self.store.memo(memo.id).version != memo.version, current["status"],
|
||
|
|
tuple(self.store.dispositions_for(p.id)))
|
||
|
|
|
||
|
|
def acknowledge(self, session, presentation_id, highlight_ids):
|
||
|
|
memo, p = self._presentation(session, presentation_id, current=True)
|
||
|
|
policy_id, observation = self._authorize(session, memo, "acknowledge")
|
||
|
|
observation.require_current(self.clock())
|
||
|
|
return self.store.acknowledge(p.id, Actor(session.subject, ActorKind.PERSON),
|
||
|
|
highlight_ids, policy_id=policy_id)
|
||
|
|
|
||
|
|
def act(self, session, presentation_id, verb, *, operation_id, reasons=(), note=None):
|
||
|
|
if verb not in (Verb.ACCEPT, Verb.RETURN, Verb.DISCUSS, Verb.DECLINE):
|
||
|
|
raise ReviewError(400, "unsupported_action")
|
||
|
|
memo, p = self._presentation(session, presentation_id, current=True)
|
||
|
|
policy_id, observation = self._authorize(session, memo, verb.value)
|
||
|
|
if verb is Verb.ACCEPT:
|
||
|
|
original = self.store.intent_for(memo.approval_id, session.subject)
|
||
|
|
if original is not None and (original["presentation_id"] != p.id or original["state"] != "prepared"):
|
||
|
|
return original["presentation_id"]
|
||
|
|
current = self._approval(session, memo)
|
||
|
|
entries = current.get("entries", [])
|
||
|
|
if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries):
|
||
|
|
raise ReviewError(503, "invalid_approval_response")
|
||
|
|
if any(e.get("subject_id") == session.subject for e in entries):
|
||
|
|
raise ReviewError(409, "existing_entry_unlinked")
|
||
|
|
if current["status"] not in ("requested", "approved"):
|
||
|
|
raise ReviewError(409, "act_unavailable")
|
||
|
|
observation.require_current(self.clock())
|
||
|
|
self._session(session)
|
||
|
|
d = self.store.record_disposition(p.id, Actor(session.subject, ActorKind.PERSON), verb,
|
||
|
|
operation_id=operation_id, reasons=reasons, note=note, policy_id=policy_id)
|
||
|
|
if verb is not Verb.ACCEPT:
|
||
|
|
return p.id
|
||
|
|
attempt = None
|
||
|
|
def before_post():
|
||
|
|
nonlocal attempt
|
||
|
|
observation.require_current(self.clock())
|
||
|
|
self._session(session)
|
||
|
|
attempt = self.store.begin_submission(d.id, policy_id=policy_id)
|
||
|
|
try:
|
||
|
|
result = self.approval_factory(session).add_entry(memo.approval_id,
|
||
|
|
expected_binding_digest=memo.approval_binding_digest, before_post=before_post)
|
||
|
|
except ApprovalEngineError:
|
||
|
|
if attempt is None:
|
||
|
|
raise
|
||
|
|
self.store.finish_submission(d.id, attempt) # May have committed; never POST again.
|
||
|
|
except Conflict:
|
||
|
|
if attempt is not None or self.store.submission(d.id)["state"] == "prepared":
|
||
|
|
raise
|
||
|
|
# Another request won the durable reservation. Its original view
|
||
|
|
# now exposes the in-flight/result state; no second POST occurs.
|
||
|
|
else:
|
||
|
|
self.store.finish_submission(d.id, attempt, result)
|
||
|
|
return p.id
|