1431 lines
47 KiB
Python
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",
|
|
]
|