Files
govoplan-approvals/src/govoplan_approvals/backend/service.py
T

1431 lines
47 KiB
Python

from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import hashlib
import json
from typing import Any
import uuid
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.approvals import (
ApprovalCheck,
ApprovalDecisionCommand,
ApprovalDecisionReceipt,
ApprovalRequestCreateCommand,
ApprovalRequestRef,
ApprovalTemplateCreateCommand,
ApprovalTemplateRef,
)
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalReplay,
ApprovalRequestRevision,
ApprovalTemplateRevision,
)
class ApprovalStoreError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ApprovalDecisionContext:
step: Mapping[str, Any]
effective_actor: str
matched_selector: Mapping[str, object]
class SqlApprovalRequests:
def create_template(
self,
session: object,
principal: object,
*,
command: ApprovalTemplateCreateCommand,
idempotency_key: str,
) -> ApprovalTemplateRef:
typed_session = _session(session)
tenant_id = _tenant(principal)
payload = _template_payload(command)
_validate_template(payload)
request = {"command": payload}
replay = _read_replay(
typed_session, tenant_id, "create_template", idempotency_key, request
)
if replay is not None:
return _template_ref_from(replay)
if (
typed_session.query(ApprovalTemplateRevision)
.filter(
ApprovalTemplateRevision.tenant_id == tenant_id,
ApprovalTemplateRevision.key == payload["key"],
ApprovalTemplateRevision.superseded_at.is_(None),
)
.first()
is not None
):
raise ApprovalStoreError("Approval template key already exists.")
row = ApprovalTemplateRevision(
tenant_id=tenant_id,
template_id=str(uuid.uuid4()),
key=str(payload["key"]),
revision=1,
state="draft",
content_sha256=_sha256(payload),
recorded_at=_now(),
payload=payload,
actor_id=_actor(principal),
)
typed_session.add(row)
typed_session.flush()
result = _template_ref(row)
_write_replay(
typed_session,
tenant_id,
"create_template",
idempotency_key,
request,
_template_ref_mapping(result),
)
return result
def revise_template(
self,
session: object,
principal: object,
*,
template_id: str,
command: ApprovalTemplateCreateCommand,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef:
typed_session = _session(session)
tenant_id = _tenant(principal)
current = _required_template_current(
typed_session, tenant_id=tenant_id, template_id=template_id, lock=True
)
payload = _template_payload(command)
_validate_template(payload)
request = {
"template_id": template_id,
"expected_revision": expected_revision,
"command": payload,
}
operation = f"revise_template:{template_id}"
replay = _read_replay(
typed_session, tenant_id, operation, idempotency_key, request
)
if replay is not None:
return _template_ref_from(replay)
if current.revision != expected_revision:
raise ApprovalStoreError(
"Approval template revision conflict: the expected revision is stale."
)
if current.key != payload["key"]:
raise ApprovalStoreError(
"Approval template keys are stable and cannot be changed."
)
revised = _revise_template(
typed_session,
current=current,
principal=principal,
state="draft",
payload=payload,
)
result = _template_ref(revised)
_write_replay(
typed_session,
tenant_id,
operation,
idempotency_key,
request,
_template_ref_mapping(result),
)
return result
def publish_template(
self,
session: object,
principal: object,
*,
template_id: str,
expected_revision: int,
idempotency_key: str,
) -> ApprovalTemplateRef:
typed_session = _session(session)
tenant_id = _tenant(principal)
current = _required_template_current(
typed_session, tenant_id=tenant_id, template_id=template_id, lock=True
)
request = {"template_id": template_id, "expected_revision": expected_revision}
operation = f"publish_template:{template_id}"
replay = _read_replay(
typed_session, tenant_id, operation, idempotency_key, request
)
if replay is not None:
return _template_ref_from(replay)
if current.revision != expected_revision:
raise ApprovalStoreError(
"Approval template revision conflict: the expected revision is stale."
)
if current.state != "draft":
raise ApprovalStoreError("Only a draft Approval template can be published.")
revised = _revise_template(
typed_session,
current=current,
principal=principal,
state="published",
payload=current.payload,
)
result = _template_ref(revised)
_write_replay(
typed_session,
tenant_id,
operation,
idempotency_key,
request,
_template_ref_mapping(result),
)
return result
def get_template(
self,
session: object,
principal: object,
*,
template_id: str,
revision: int | None = None,
) -> Mapping[str, object] | None:
query = (
_session(session)
.query(ApprovalTemplateRevision)
.filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id,
)
)
if revision is None:
query = query.filter(ApprovalTemplateRevision.superseded_at.is_(None))
else:
query = query.filter(ApprovalTemplateRevision.revision == revision)
row = query.one_or_none()
return _template_mapping(row) if row is not None else None
def list_templates(
self,
session: object,
principal: object,
*,
state: str | None = None,
limit: int = 100,
) -> tuple[Mapping[str, object], ...]:
if not 1 <= limit <= 200:
raise ApprovalStoreError(
"Approval template list limit must be between 1 and 200."
)
query = (
_session(session)
.query(ApprovalTemplateRevision)
.filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.superseded_at.is_(None),
)
)
if state:
query = query.filter(ApprovalTemplateRevision.state == state)
return tuple(
_template_mapping(row)
for row in query.order_by(ApprovalTemplateRevision.key.asc())
.limit(limit)
.all()
)
def template_history(
self,
session: object,
principal: object,
*,
template_id: str,
) -> tuple[Mapping[str, object], ...]:
rows = (
_session(session)
.query(ApprovalTemplateRevision)
.filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id,
)
.order_by(ApprovalTemplateRevision.revision.desc())
.all()
)
if not rows:
raise LookupError("Approval template not found.")
return tuple(_template_mapping(row) for row in rows)
def compare_template_revisions(
self,
session: object,
principal: object,
*,
template_id: str,
from_revision: int,
to_revision: int,
) -> Mapping[str, object]:
if from_revision < 1 or to_revision < 1:
raise ApprovalStoreError(
"Approval template revisions must be positive integers."
)
rows = (
_session(session)
.query(ApprovalTemplateRevision)
.filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id,
ApprovalTemplateRevision.revision.in_((from_revision, to_revision)),
)
.all()
)
by_revision = {row.revision: row for row in rows}
missing = [
revision
for revision in (from_revision, to_revision)
if revision not in by_revision
]
if missing:
raise LookupError(
"Approval template revision not found: "
+ ", ".join(str(revision) for revision in missing)
)
before = by_revision[from_revision]
after = by_revision[to_revision]
return {
"template_id": template_id,
"from_revision": _template_mapping(before),
"to_revision": _template_mapping(after),
"changes": _structured_changes(
before.payload,
after.payload,
),
}
def create_request(
self,
session: object,
principal: object,
*,
command: ApprovalRequestCreateCommand,
idempotency_key: str,
) -> ApprovalRequestRef:
typed_session = _session(session)
tenant_id = _tenant(principal)
template = None
if command.template_id is not None:
if command.template_revision is None:
raise ApprovalStoreError(
"Approval template revision is required with a template id."
)
if command.steps:
raise ApprovalStoreError(
"Approval requests cannot combine template and inline steps."
)
template = (
typed_session.query(ApprovalTemplateRevision)
.filter(
ApprovalTemplateRevision.tenant_id == tenant_id,
ApprovalTemplateRevision.template_id == command.template_id,
ApprovalTemplateRevision.revision == command.template_revision,
)
.one_or_none()
)
if template is None or template.state != "published":
raise ApprovalStoreError(
"The exact published Approval template revision is unavailable."
)
payload = _payload(command, requested_by=_actor(principal), template=template)
_validate(payload)
request = {"command": payload}
replay = _read_replay(
typed_session, tenant_id, "create", idempotency_key, request
)
if replay is not None:
return _ref_from(replay)
request_id = str(uuid.uuid4())
now = _now()
first_step = str(payload["steps"][0]["key"])
row = ApprovalRequestRevision(
tenant_id=tenant_id,
request_id=request_id,
revision=1,
state="pending",
current_step_key=first_step,
subject_module=str(payload["subject_module"]),
subject_type=str(payload["subject_type"]),
subject_id=str(payload["subject_id"]),
subject_version=_text(payload.get("subject_version")),
subject_digest=str(payload["subject_digest"]),
recorded_at=now,
payload=payload,
actor_id=_actor(principal),
)
typed_session.add(row)
typed_session.flush()
_event(
typed_session,
row,
principal,
"approval.requested",
{"revision": 1, "step_key": first_step},
)
result = _ref(row)
_write_replay(
typed_session,
tenant_id,
"create",
idempotency_key,
request,
_ref_mapping(result),
)
return result
def get_request(
self, session: object, principal: object, *, request_id: str
) -> Mapping[str, object] | None:
row = _find_current(
_session(session),
tenant_id=_tenant(principal),
request_id=request_id,
lock=False,
)
return _mapping(row) if row is not None else None
def list_requests(
self,
session: object,
principal: object,
*,
state: str | None = None,
subject_module: str | None = None,
subject_id: str | None = None,
limit: int = 100,
) -> tuple[Mapping[str, object], ...]:
typed_session = _session(session)
if not 1 <= limit <= 200:
raise ApprovalStoreError("Approval list limit must be between 1 and 200.")
query = typed_session.query(ApprovalRequestRevision).filter(
ApprovalRequestRevision.tenant_id == _tenant(principal),
ApprovalRequestRevision.superseded_at.is_(None),
)
if state:
query = query.filter(ApprovalRequestRevision.state == state)
if subject_module:
query = query.filter(
ApprovalRequestRevision.subject_module == subject_module
)
if subject_id:
query = query.filter(ApprovalRequestRevision.subject_id == subject_id)
return tuple(
_mapping(row)
for row in query.order_by(ApprovalRequestRevision.recorded_at.desc())
.limit(limit)
.all()
)
def decide(
self,
session: object,
principal: object,
*,
request_id: str,
command: ApprovalDecisionCommand,
) -> ApprovalDecisionReceipt:
typed_session = _session(session)
tenant_id = _tenant(principal)
actor_id = _actor(principal)
if actor_id is None:
raise ApprovalStoreError(
"Approval decisions require an authenticated account."
)
outcome = str(command.outcome or "").strip().lower()
if outcome not in {"approved", "rejected"}:
raise ApprovalStoreError("Approval outcome must be approved or rejected.")
reason = str(command.reason or "").strip()
if not reason:
raise ApprovalStoreError("Approval decisions require a reason.")
idempotency_key = _idempotency(command.idempotency_key)
existing = (
typed_session.query(ApprovalDecisionRecord)
.filter(
ApprovalDecisionRecord.tenant_id == tenant_id,
ApprovalDecisionRecord.request_id == request_id,
ApprovalDecisionRecord.idempotency_key == idempotency_key,
)
.one_or_none()
)
if existing is not None:
if existing.outcome != outcome or existing.reason != reason:
raise ApprovalStoreError(
"Approval idempotency key was reused for another decision."
)
replay_state = _required_current(
typed_session, tenant_id=tenant_id, request_id=request_id, lock=False
).state
return _decision_receipt(existing, state=replay_state, replayed=True)
current = _required_current(
typed_session, tenant_id=tenant_id, request_id=request_id, lock=True
)
if current.revision != command.expected_revision:
raise ApprovalStoreError(
"Approval revision conflict: the expected revision is stale."
)
if current.state not in {"pending", "escalated"}:
raise ApprovalStoreError(
"This Approval request no longer accepts decisions."
)
decision_context = approval_decision_context(
typed_session,
principal,
current,
delegated_for_account_id=command.delegated_for_account_id,
)
steps = list(current.payload["steps"])
step_index = int(current.payload.get("current_step_index") or 0)
step = decision_context.step
effective_actor = decision_context.effective_actor
matched_selector = decision_context.matched_selector
signature_ref = (
dict(command.signature_ref) if command.signature_ref is not None else None
)
if bool(step.get("signature_required")) and not signature_ref:
raise ApprovalStoreError(
"This Approval step requires a signature reference."
)
recorded_at = _now()
delegation_id = _delegation_id(principal, command.delegated_for_account_id)
authority_provenance = {
"matched_selector": matched_selector,
"principal_actor_id": actor_id,
"effective_actor_id": effective_actor,
"delegation_id": delegation_id,
}
receipt_hash = _sha256(
{
"tenant_id": tenant_id,
"request_id": request_id,
"revision": current.revision + 1,
"step_key": step["key"],
"outcome": outcome,
"reason": reason,
"actor_id": actor_id,
"effective_actor_id": effective_actor,
"authority_provenance": authority_provenance,
"signature_ref": signature_ref,
"recorded_at": recorded_at.isoformat(),
}
)
decision = ApprovalDecisionRecord(
tenant_id=tenant_id,
request_id=request_id,
request_revision=current.revision + 1,
step_key=str(step["key"]),
outcome=outcome,
reason=reason,
actor_id=actor_id,
effective_actor_id=effective_actor,
delegation_id=delegation_id,
authority_provenance=authority_provenance,
signature_ref=signature_ref,
recorded_at=recorded_at,
idempotency_key=idempotency_key,
receipt_sha256=receipt_hash,
)
typed_session.add(decision)
typed_session.flush()
payload = dict(current.payload)
state = current.state
next_step_key: str | None = str(step["key"])
if (
outcome == "rejected"
and str(step.get("rejection_policy") or "fail_fast") == "fail_fast"
):
state = "rejected"
next_step_key = None
payload["completed_at"] = recorded_at.isoformat()
elif outcome == "approved":
count = (
typed_session.query(ApprovalDecisionRecord)
.filter(
ApprovalDecisionRecord.tenant_id == tenant_id,
ApprovalDecisionRecord.request_id == request_id,
ApprovalDecisionRecord.step_key == step["key"],
ApprovalDecisionRecord.outcome == "approved",
)
.count()
)
if count >= int(step.get("required_approvals") or 1):
step_index += 1
payload["current_step_index"] = step_index
if step_index >= len(steps):
state = "approved"
next_step_key = None
payload["completed_at"] = recorded_at.isoformat()
else:
state = "pending"
next_step_key = str(steps[step_index]["key"])
revised = _revise(
typed_session,
current=current,
principal=principal,
state=state,
current_step_key=next_step_key,
payload=payload,
event_type="approval.decision_recorded",
event_payload={
"step_key": step["key"],
"outcome": outcome,
"receipt_sha256": receipt_hash,
"effective_actor_id": effective_actor,
"authority_provenance": authority_provenance,
"next_step_key": next_step_key,
},
)
decision.request_revision = revised.revision
typed_session.flush()
return _decision_receipt(decision, state=revised.state)
def check_approved(
self,
session: object,
principal: object,
*,
request_id: str,
subject_module: str,
subject_type: str,
subject_id: str,
subject_version: str | None,
subject_digest: str,
) -> ApprovalCheck:
row = _required_current(
_session(session),
tenant_id=_tenant(principal),
request_id=request_id,
lock=False,
)
expected = (subject_module, subject_type, subject_id, subject_version)
actual = (
row.subject_module,
row.subject_type,
row.subject_id,
row.subject_version,
)
if expected != actual:
raise ApprovalStoreError(
"Approval request does not cover the exact requested subject revision."
)
if row.subject_digest != str(subject_digest or "").strip().lower():
raise ApprovalStoreError(
"Approval request does not cover the exact requested subject content digest."
)
return ApprovalCheck(
request_id=request_id,
revision=row.revision,
state=row.state,
approved=row.state == "approved",
subject_module=row.subject_module,
subject_type=row.subject_type,
subject_id=row.subject_id,
subject_version=row.subject_version,
subject_digest=row.subject_digest,
completed_at=_datetime(row.payload.get("completed_at")),
)
def escalate_due(
self,
session: object,
principal: object,
*,
request_id: str,
expected_revision: int,
idempotency_key: str,
) -> ApprovalRequestRef:
typed_session = _session(session)
current = _required_current(
typed_session,
tenant_id=_tenant(principal),
request_id=request_id,
lock=True,
)
request = {"request_id": request_id, "expected_revision": expected_revision}
operation = f"escalate:{request_id}"
replay = _read_replay(
typed_session, current.tenant_id, operation, idempotency_key, request
)
if replay is not None:
return _ref_from(replay)
if current.revision != expected_revision:
raise ApprovalStoreError(
"Approval revision conflict: the expected revision is stale."
)
if current.state != "pending":
raise ApprovalStoreError(
"Only a pending Approval request can be escalated."
)
step = current.payload["steps"][
int(current.payload.get("current_step_index") or 0)
]
due_at = _datetime(step.get("due_at"))
if due_at is None or _now() < due_at:
raise ApprovalStoreError(
"The current Approval step is not due for escalation."
)
revised = _revise(
typed_session,
current=current,
principal=principal,
state="escalated",
current_step_key=current.current_step_key,
payload=current.payload,
event_type="approval.escalated",
event_payload={
"step_key": current.current_step_key,
"due_at": _iso(due_at),
},
)
response = _ref_mapping(_ref(revised))
_write_replay(
typed_session,
current.tenant_id,
operation,
idempotency_key,
request,
response,
)
return _ref_from(response)
def history(
self, session: object, principal: object, *, request_id: str
) -> tuple[Mapping[str, object], ...]:
typed_session = _session(session)
rows = (
typed_session.query(ApprovalLifecycleEvent)
.filter(
ApprovalLifecycleEvent.tenant_id == _tenant(principal),
ApprovalLifecycleEvent.request_id == request_id,
)
.order_by(ApprovalLifecycleEvent.sequence.asc())
.all()
)
return tuple(
{
"sequence": row.sequence,
"event_type": row.event_type,
"recorded_at": _iso(row.recorded_at),
"actor_id": row.actor_id,
"payload": dict(row.payload),
}
for row in rows
)
def approval_decision_context(
session: Session,
principal: object,
request: ApprovalRequestRevision,
*,
delegated_for_account_id: str | None = None,
prior_decisions: Sequence[ApprovalDecisionRecord] | None = None,
) -> ApprovalDecisionContext:
if request.state not in {"pending", "escalated"}:
raise ApprovalStoreError("This Approval request no longer accepts decisions.")
expires_at = _datetime(request.payload.get("expires_at"))
if expires_at is not None and _now() >= expires_at:
raise ApprovalStoreError("This Approval request has expired.")
steps = list(request.payload.get("steps") or ())
step_index = int(request.payload.get("current_step_index") or 0)
if step_index < 0 or step_index >= len(steps):
raise ApprovalStoreError("The current Approval step is unavailable.")
step = steps[step_index]
effective_actor = _effective_actor(principal, delegated_for_account_id)
matched_selector = _matched_selector(
principal,
list(step.get("selectors") or ()),
effective_actor,
)
if matched_selector is None:
raise ApprovalStoreError(
"The current principal is not eligible for this Approval step."
)
if bool(
request.payload.get("separation_of_duties")
) and effective_actor == request.payload.get("requested_by"):
raise ApprovalStoreError(
"Approval separation of duties prevents requester self-approval."
)
prior = (
list(prior_decisions)
if prior_decisions is not None
else (
session.query(ApprovalDecisionRecord)
.filter(
ApprovalDecisionRecord.tenant_id == request.tenant_id,
ApprovalDecisionRecord.request_id == request.request_id,
ApprovalDecisionRecord.effective_actor_id == effective_actor,
)
.all()
)
)
if any(item.step_key == step["key"] for item in prior):
raise ApprovalStoreError(
"This actor has already decided the current Approval step."
)
if bool(request.payload.get("unique_actors_across_steps")) and any(
item.outcome == "approved" for item in prior
):
raise ApprovalStoreError(
"Approval policy requires a different actor for each step."
)
evidence_actors = {
str(key): {str(actor) for actor in (actors or [])}
for key, actors in dict(request.payload.get("evidence_actors") or {}).items()
}
for role in step.get("forbidden_evidence_roles") or ():
if effective_actor in evidence_actors.get(str(role), set()):
raise ApprovalStoreError(
f"Approval separation of duties prevents the {role} actor from deciding this step."
)
return ApprovalDecisionContext(
step=step,
effective_actor=effective_actor,
matched_selector=matched_selector,
)
def _step_payload(step: object) -> dict[str, Any]:
return {
"key": str(getattr(step, "key")),
"label": str(getattr(step, "label")),
"selectors": [asdict(selector) for selector in getattr(step, "selectors")],
"required_approvals": int(getattr(step, "required_approvals")),
"rejection_policy": str(getattr(step, "rejection_policy")),
"due_at": _iso(getattr(step, "due_at")),
"signature_required": bool(getattr(step, "signature_required")),
"forbidden_evidence_roles": list(getattr(step, "forbidden_evidence_roles", ())),
"metadata": dict(getattr(step, "metadata")),
}
def _template_payload(command: ApprovalTemplateCreateCommand) -> dict[str, Any]:
return {
"key": str(command.key or "").strip(),
"title": str(command.title or "").strip(),
"description": _text(command.description),
"steps": [_step_payload(step) for step in command.steps],
"separation_of_duties": command.separation_of_duties,
"unique_actors_across_steps": command.unique_actors_across_steps,
"metadata": dict(command.metadata),
}
def _validate_template(payload: Mapping[str, Any]) -> None:
if not str(payload.get("key") or "").strip():
raise ApprovalStoreError("Approval template key is required.")
if not str(payload.get("title") or "").strip():
raise ApprovalStoreError("Approval template title is required.")
_validate_steps(list(payload.get("steps") or []))
def _payload(
command: ApprovalRequestCreateCommand,
*,
requested_by: str | None,
template: ApprovalTemplateRevision | None,
) -> dict[str, Any]:
template_payload = dict(template.payload) if template is not None else None
steps = (
list(template_payload["steps"])
if template_payload is not None
else [_step_payload(step) for step in command.steps]
)
return {
"title": str(command.title or "").strip(),
"description": _text(command.description),
"subject_module": str(command.subject_module or "").strip(),
"subject_type": str(command.subject_type or "").strip(),
"subject_id": str(command.subject_id or "").strip(),
"subject_version": _text(command.subject_version),
"subject_digest": str(command.subject_digest or "").strip().lower(),
"steps": steps,
"current_step_index": 0,
"separation_of_duties": (
bool(template_payload["separation_of_duties"])
if template_payload is not None
else command.separation_of_duties
),
"unique_actors_across_steps": (
bool(template_payload["unique_actors_across_steps"])
if template_payload is not None
else command.unique_actors_across_steps
),
"expires_at": _iso(command.expires_at),
"policy_refs": list(command.policy_refs),
"evidence_actors": {
str(role): sorted({str(actor) for actor in actors if str(actor).strip()})
for role, actors in command.evidence_actors.items()
},
"template": (
{
"id": template.template_id,
"key": template.key,
"revision": template.revision,
"content_sha256": template.content_sha256,
}
if template is not None
else None
),
"metadata": dict(command.metadata),
"requested_by": requested_by,
"completed_at": None,
}
def _validate(payload: Mapping[str, Any]) -> None:
for key in ("title", "subject_module", "subject_type", "subject_id"):
if not str(payload.get(key) or "").strip():
raise ApprovalStoreError(f"Approval {key.replace('_', ' ')} is required.")
digest = str(payload.get("subject_digest") or "").strip().lower()
if len(digest) != 64 or any(
character not in "0123456789abcdef" for character in digest
):
raise ApprovalStoreError(
"Approval subject digest must be a lowercase SHA-256 digest."
)
_validate_steps(
list(payload.get("steps") or []),
evidence_actors=dict(payload.get("evidence_actors") or {}),
)
def _validate_steps(
steps: list[Mapping[str, Any]],
*,
evidence_actors: Mapping[str, object] | None = None,
) -> None:
keys = [str(item.get("key") or "").strip() for item in steps]
if not steps or any(not key for key in keys) or len(keys) != len(set(keys)):
raise ApprovalStoreError("Approval steps require unique non-empty keys.")
for step in steps:
selectors = list(step.get("selectors") or [])
if not selectors:
raise ApprovalStoreError("Each Approval step requires an actor selector.")
allowed = {"account", "group", "role", "function_assignment", "any_account"}
if any(
str(item.get("kind") or "") not in allowed
or not str(item.get("value") or "").strip()
for item in selectors
):
raise ApprovalStoreError("Approval actor selector is invalid.")
required = int(step.get("required_approvals") or 0)
if required < 1:
raise ApprovalStoreError("Approval required_approvals must be positive.")
if str(step.get("rejection_policy") or "") not in {"fail_fast", "collect"}:
raise ApprovalStoreError("Approval rejection policy is invalid.")
forbidden_roles = [
str(role).strip() for role in step.get("forbidden_evidence_roles") or []
]
if any(not role for role in forbidden_roles) or len(forbidden_roles) != len(
set(forbidden_roles)
):
raise ApprovalStoreError(
"Approval forbidden evidence roles must be unique non-empty values."
)
if evidence_actors is not None:
missing = [
role
for role in forbidden_roles
if not list(evidence_actors.get(role) or [])
]
if missing:
raise ApprovalStoreError(
"Approval evidence actors are required for constrained roles: "
+ ", ".join(sorted(missing))
)
def _matched_selector(
principal: object,
selectors: list[Mapping[str, Any]],
effective_actor: str,
) -> dict[str, object] | None:
groups = set(getattr(principal, "group_ids", ()) or ())
roles = set(getattr(principal, "role_ids", ()) or ())
functions = set(getattr(principal, "function_assignment_ids", ()) or ())
for selector in selectors:
kind = str(selector.get("kind") or "")
value = str(selector.get("value") or "")
if kind == "account" and value == effective_actor:
return dict(selector)
if kind == "any_account" and value == "*":
return dict(selector)
if kind == "group" and value in groups:
return dict(selector)
if kind == "role" and value in roles:
return dict(selector)
if kind == "function_assignment" and value in functions:
return dict(selector)
return None
def _effective_actor(principal: object, delegated_for: str | None) -> str:
actor = _actor(principal)
if actor is None:
raise ApprovalStoreError("Approval decisions require an authenticated account.")
target = _text(delegated_for)
if target is None:
return actor
if _text(getattr(principal, "acting_for_account_id", None)) != target:
raise ApprovalStoreError(
"Approval delegation is not present in the trusted principal."
)
return target
def _delegation_id(principal: object, delegated_for: str | None) -> str | None:
if not _text(delegated_for):
return None
direct = _text(getattr(principal, "acting_assignment_id", None))
if direct:
return direct
values = getattr(principal, "delegation_ids", ()) or ()
if isinstance(values, str):
return _text(values)
normalized = sorted(str(item).strip() for item in values if str(item).strip())
return normalized[0] if normalized else None
def _required_current(
session: Session, *, tenant_id: str, request_id: str, lock: bool
) -> ApprovalRequestRevision:
row = _find_current(session, tenant_id=tenant_id, request_id=request_id, lock=lock)
if row is None:
raise LookupError("Approval request not found.")
return row
def _find_current(
session: Session, *, tenant_id: str, request_id: str, lock: bool
) -> ApprovalRequestRevision | None:
query = session.query(ApprovalRequestRevision).filter(
ApprovalRequestRevision.tenant_id == tenant_id,
ApprovalRequestRevision.request_id == request_id,
ApprovalRequestRevision.superseded_at.is_(None),
)
if lock:
query = query.with_for_update()
return query.one_or_none()
def _revise(
session: Session,
*,
current: ApprovalRequestRevision,
principal: object,
state: str,
current_step_key: str | None,
payload: Mapping[str, Any],
event_type: str,
event_payload: Mapping[str, Any],
) -> ApprovalRequestRevision:
now = _now()
current.superseded_at = now
row = ApprovalRequestRevision(
tenant_id=current.tenant_id,
request_id=current.request_id,
revision=current.revision + 1,
previous_revision_id=current.id,
state=state,
current_step_key=current_step_key,
subject_module=current.subject_module,
subject_type=current.subject_type,
subject_id=current.subject_id,
subject_version=current.subject_version,
subject_digest=current.subject_digest,
recorded_at=now,
payload=dict(payload),
actor_id=_actor(principal),
)
session.add(row)
session.flush()
_event(session, row, principal, event_type, event_payload)
return row
def _event(
session: Session,
row: ApprovalRequestRevision,
principal: object,
event_type: str,
payload: Mapping[str, Any],
) -> None:
sequence = (
session.query(func.max(ApprovalLifecycleEvent.sequence))
.filter(
ApprovalLifecycleEvent.tenant_id == row.tenant_id,
ApprovalLifecycleEvent.request_id == row.request_id,
)
.scalar()
or 0
) + 1
session.add(
ApprovalLifecycleEvent(
tenant_id=row.tenant_id,
request_id=row.request_id,
sequence=sequence,
event_type=event_type,
recorded_at=_now(),
actor_id=_actor(principal),
payload=dict(payload),
)
)
session.flush()
def _mapping(row: ApprovalRequestRevision) -> dict[str, object]:
return {
"id": row.request_id,
"revision": row.revision,
"state": row.state,
"current_step_key": row.current_step_key,
"recorded_at": _iso(row.recorded_at),
**dict(row.payload),
}
def _ref(row: ApprovalRequestRevision) -> ApprovalRequestRef:
return ApprovalRequestRef(
row.request_id, row.revision, row.state, row.current_step_key
)
def _ref_mapping(value: ApprovalRequestRef) -> dict[str, object]:
return {
"id": value.id,
"revision": value.revision,
"state": value.state,
"current_step_key": value.current_step_key,
}
def _ref_from(value: Mapping[str, Any]) -> ApprovalRequestRef:
return ApprovalRequestRef(
str(value["id"]),
int(value["revision"]),
str(value["state"]),
_text(value.get("current_step_key")),
)
def _required_template_current(
session: Session,
*,
tenant_id: str,
template_id: str,
lock: bool,
) -> ApprovalTemplateRevision:
query = session.query(ApprovalTemplateRevision).filter(
ApprovalTemplateRevision.tenant_id == tenant_id,
ApprovalTemplateRevision.template_id == template_id,
ApprovalTemplateRevision.superseded_at.is_(None),
)
if lock:
query = query.with_for_update()
row = query.one_or_none()
if row is None:
raise LookupError("Approval template not found.")
return row
def _revise_template(
session: Session,
*,
current: ApprovalTemplateRevision,
principal: object,
state: str,
payload: Mapping[str, Any],
) -> ApprovalTemplateRevision:
now = _now()
current.superseded_at = now
row = ApprovalTemplateRevision(
tenant_id=current.tenant_id,
template_id=current.template_id,
key=current.key,
revision=current.revision + 1,
previous_revision_id=current.id,
state=state,
content_sha256=_sha256(payload),
recorded_at=now,
payload=dict(payload),
actor_id=_actor(principal),
)
session.add(row)
session.flush()
return row
def _template_mapping(row: ApprovalTemplateRevision) -> dict[str, object]:
return {
"id": row.template_id,
"revision": row.revision,
"state": row.state,
"content_sha256": row.content_sha256,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"previous_revision_id": row.previous_revision_id,
"actor_id": row.actor_id,
**dict(row.payload),
}
_MISSING = object()
def _structured_changes(
before: object,
after: object,
*,
path: str = "",
) -> list[dict[str, object]]:
if isinstance(before, Mapping) and isinstance(after, Mapping):
changes: list[dict[str, object]] = []
keys = sorted(set(before).union(after), key=str)
for key in keys:
child_path = f"{path}/{_json_pointer_segment(str(key))}"
changes.extend(
_structured_changes(
before.get(key, _MISSING),
after.get(key, _MISSING),
path=child_path,
)
)
return changes
if isinstance(before, list) and isinstance(after, list):
changes = []
for index in range(max(len(before), len(after))):
changes.extend(
_structured_changes(
before[index] if index < len(before) else _MISSING,
after[index] if index < len(after) else _MISSING,
path=f"{path}/{index}",
)
)
return changes
if before is _MISSING:
return [
{
"path": path or "/",
"change": "added",
"before": None,
"after": after,
}
]
if after is _MISSING:
return [
{
"path": path or "/",
"change": "removed",
"before": before,
"after": None,
}
]
if before != after:
return [
{
"path": path or "/",
"change": "changed",
"before": before,
"after": after,
}
]
return []
def _json_pointer_segment(value: str) -> str:
return value.replace("~", "~0").replace("/", "~1")
def _template_ref(row: ApprovalTemplateRevision) -> ApprovalTemplateRef:
return ApprovalTemplateRef(
id=row.template_id,
key=row.key,
revision=row.revision,
state=row.state,
content_sha256=row.content_sha256,
)
def _template_ref_mapping(value: ApprovalTemplateRef) -> dict[str, object]:
return {
"id": value.id,
"key": value.key,
"revision": value.revision,
"state": value.state,
"content_sha256": value.content_sha256,
}
def _template_ref_from(value: Mapping[str, Any]) -> ApprovalTemplateRef:
return ApprovalTemplateRef(
id=str(value["id"]),
key=str(value["key"]),
revision=int(value["revision"]),
state=str(value["state"]),
content_sha256=str(value["content_sha256"]),
)
def _decision_receipt(
row: ApprovalDecisionRecord,
*,
state: str,
replayed: bool = False,
) -> ApprovalDecisionReceipt:
return ApprovalDecisionReceipt(
request_id=row.request_id,
revision=row.request_revision,
state=state,
step_key=row.step_key,
outcome=row.outcome,
actor_id=row.actor_id,
recorded_at=_aware(row.recorded_at),
receipt_sha256=row.receipt_sha256,
authority_provenance=dict(row.authority_provenance),
replayed=replayed,
)
def _read_replay(
session: Session,
tenant_id: str,
operation: str,
key: str,
request: Mapping[str, Any],
) -> Mapping[str, Any] | None:
normalized = _idempotency(key)
row = (
session.query(ApprovalReplay)
.filter(
ApprovalReplay.tenant_id == tenant_id,
ApprovalReplay.operation == operation,
ApprovalReplay.idempotency_key == normalized,
)
.one_or_none()
)
if row is None:
return None
if row.request_sha256 != _sha256(request):
raise ApprovalStoreError(
"Approval idempotency key was reused for another command."
)
return dict(row.response)
def _write_replay(
session: Session,
tenant_id: str,
operation: str,
key: str,
request: Mapping[str, Any],
response: Mapping[str, Any],
) -> None:
session.add(
ApprovalReplay(
tenant_id=tenant_id,
operation=operation,
idempotency_key=_idempotency(key),
request_sha256=_sha256(request),
response=dict(response),
)
)
session.flush()
def _sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
default=lambda item: (
_iso(item) if isinstance(item, datetime) else str(item)
),
).encode()
).hexdigest()
def _idempotency(value: str) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > 160:
raise ApprovalStoreError(
"Approval idempotency key is required and limited to 160 characters."
)
return normalized
def _tenant(principal: object) -> str:
value = str(getattr(principal, "tenant_id", "") or "").strip()
if not value:
raise ApprovalStoreError(
"Approval operations require a tenant-bound principal."
)
return value
def _actor(principal: object) -> str | None:
for name in ("account_id", "identity_id", "membership_id"):
value = _text(getattr(principal, name, None))
if value:
return value
return None
def _session(value: object) -> Session:
if not hasattr(value, "query"):
raise ApprovalStoreError("Approvals requires a database session.")
return value # type: ignore[return-value]
def _text(value: object) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _datetime(value: object) -> datetime | None:
if value in (None, ""):
return None
parsed = (
value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
)
return _aware(parsed)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat() if value is not None else None
def _now() -> datetime:
return datetime.now(UTC)
__all__ = [
"ApprovalDecisionContext",
"ApprovalStoreError",
"SqlApprovalRequests",
"approval_decision_context",
]