Project pending approvals as authorized work

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent 956624be0e
commit b691a0c2d5
8 changed files with 449 additions and 67 deletions
+42 -6
View File
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_approvals.backend.db import models as approval_models
@@ -39,7 +40,14 @@ READ_SCOPE = "approvals:workspace:read"
WRITE_SCOPE = "approvals:workspace:write"
DECIDE_SCOPE = "approvals:workspace:decide"
ADMIN_SCOPE = "approvals:workspace:admin"
OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy")
OPTIONAL_DEPENDENCIES = (
"workflow_engine",
"audit",
"files",
"notifications",
"policy",
"tasks",
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -66,6 +74,12 @@ def _requests(_context: ModuleContext) -> SqlApprovalRequests:
return SqlApprovalRequests()
def _work_items(_context: ModuleContext):
from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
return ApprovalWorkItemProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
@@ -196,6 +210,13 @@ manifest = ModuleManifest(
contract_version="0.1.0",
)
},
work_item_providers=(
WorkItemProviderRegistration(
id="approvals.pending",
factory=_work_items,
order=30,
),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
@@ -229,7 +250,8 @@ manifest = ModuleManifest(
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
body=(
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. "
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables."
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables. "
"When Tasks is enabled, a pending step appears in the common work inbox only for a principal who currently passes the exact decision eligibility checks."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -312,10 +334,24 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner", "auditor"),
links=(
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"),
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"),
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"),
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"),
DocumentationLink(
label="Approval templates",
href="/admin?section=tenant-approval-templates",
kind="runtime",
),
DocumentationLink(
label="Template API", href="/api/v1/approvals/templates", kind="api"
),
DocumentationLink(
label="Template history API",
href="/api/v1/approvals/templates/{template_id}/history",
kind="api",
),
DocumentationLink(
label="Template comparison API",
href="/api/v1/approvals/templates/{template_id}/compare",
kind="api",
),
),
metadata={
"help_contexts": [
+98 -54
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import hashlib
import json
@@ -33,6 +33,13 @@ 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,
@@ -278,9 +285,7 @@ class SqlApprovalRequests:
.filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id,
ApprovalTemplateRevision.revision.in_(
(from_revision, to_revision)
),
ApprovalTemplateRevision.revision.in_((from_revision, to_revision)),
)
.all()
)
@@ -480,56 +485,17 @@ class SqlApprovalRequests:
raise ApprovalStoreError(
"This Approval request no longer accepts decisions."
)
expires_at = _datetime(current.payload.get("expires_at"))
if expires_at is not None and _now() >= expires_at:
raise ApprovalStoreError("This Approval request has expired.")
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 = steps[step_index]
effective_actor = _effective_actor(principal, command.delegated_for_account_id)
matched_selector = _matched_selector(
principal, 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(
current.payload.get("separation_of_duties")
) and effective_actor == current.payload.get("requested_by"):
raise ApprovalStoreError(
"Approval separation of duties prevents requester self-approval."
)
prior = (
typed_session.query(ApprovalDecisionRecord)
.filter(
ApprovalDecisionRecord.tenant_id == tenant_id,
ApprovalDecisionRecord.request_id == 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(current.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(
current.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."
)
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
)
@@ -764,6 +730,79 @@ class SqlApprovalRequests:
)
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")),
@@ -1383,4 +1422,9 @@ def _now() -> datetime:
return datetime.now(UTC)
__all__ = ["ApprovalStoreError", "SqlApprovalRequests"]
__all__ = [
"ApprovalDecisionContext",
"ApprovalStoreError",
"SqlApprovalRequests",
"approval_decision_context",
]
@@ -0,0 +1,232 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from datetime import UTC, datetime
from urllib.parse import quote
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalRequestRevision,
)
from govoplan_approvals.backend.service import (
ApprovalStoreError,
approval_decision_context,
)
PROVIDER_ID = "approvals.pending"
READ_SCOPE = "approvals:workspace:read"
DECIDE_SCOPE = "approvals:workspace:decide"
class ApprovalWorkItemProvider:
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Approval work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id:
return WorkItemPage(items=(), total=0)
if not has_scope(principal, READ_SCOPE) or not has_scope(
principal, DECIDE_SCOPE
):
return WorkItemPage(items=(), total=0)
if query.statuses and "open" not in query.statuses:
return WorkItemPage(items=(), total=0)
rows = list(
session.scalars(
select(ApprovalRequestRevision)
.where(
ApprovalRequestRevision.tenant_id == query.tenant_id,
ApprovalRequestRevision.superseded_at.is_(None),
ApprovalRequestRevision.state.in_(("pending", "escalated")),
)
.order_by(
ApprovalRequestRevision.recorded_at.asc(),
ApprovalRequestRevision.request_id.asc(),
)
)
)
prior_by_request: dict[str, list[ApprovalDecisionRecord]] = defaultdict(list)
if rows and principal.account_id:
decisions = session.scalars(
select(ApprovalDecisionRecord).where(
ApprovalDecisionRecord.tenant_id == query.tenant_id,
ApprovalDecisionRecord.effective_actor_id == principal.account_id,
ApprovalDecisionRecord.request_id.in_(
tuple(row.request_id for row in rows)
),
)
)
for decision in decisions:
prior_by_request[decision.request_id].append(decision)
items: list[WorkItem] = []
total = 0
now = datetime.now(UTC)
for row in rows:
try:
context = approval_decision_context(
session,
principal,
row,
prior_decisions=prior_by_request.get(row.request_id, ()),
)
except ApprovalStoreError:
continue
item = _work_item(row, context.step, now=now)
if query.priorities and item.priority not in query.priorities:
continue
if query.due_before is not None and (
item.due_at is None or _aware(item.due_at) > _aware(query.due_before)
):
continue
if query.text and query.text.casefold() not in _search_text(item):
continue
total += 1
if len(items) < query.limit:
items.append(item)
items.sort(key=_sort_key)
return WorkItemPage(
items=tuple(items),
total=total,
truncated=total > len(items),
)
def _work_item(
row: ApprovalRequestRevision,
step: Mapping[str, object],
*,
now: datetime,
) -> WorkItem:
payload = dict(row.payload or {})
due_at = _date(step.get("due_at")) or _date(payload.get("expires_at"))
priority = "high" if row.state == "escalated" else "normal"
if due_at is not None and _aware(due_at) < now:
priority = "urgent"
title = str(payload.get("title") or "Approval required").strip()
step_label = str(step.get("label") or row.current_step_key or "Decide").strip()
action_url = f"/approvals?request={quote(row.request_id, safe='')}"
return WorkItem(
id=row.request_id,
provider_id=PROVIDER_ID,
owner_module="approvals",
tenant_id=row.tenant_id,
title=title,
summary=(
str(payload.get("description") or "").strip()
or f"{row.subject_module}: {row.subject_type}"
),
status="open",
priority=priority, # type: ignore[arg-type]
required_action=step_label,
action_url=action_url,
due_at=due_at,
assignments=_assignments(step),
sources=(
WorkSourceRef(
module_id=row.subject_module,
resource_type=row.subject_type,
resource_id=row.subject_id,
revision=row.subject_version,
),
WorkSourceRef(
module_id="approvals",
resource_type="approval_request",
resource_id=row.request_id,
revision=str(row.revision),
url=action_url,
label=title,
),
),
provenance={
"subject_digest": row.subject_digest,
"policy_refs": list(payload.get("policy_refs") or ()),
"template": payload.get("template"),
},
metadata={
"current_step_key": row.current_step_key,
"signature_required": bool(step.get("signature_required")),
"approval_state": row.state,
},
revision=str(row.revision),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _assignments(step: Mapping[str, object]) -> tuple[WorkAssignmentRef, ...]:
assignments: list[WorkAssignmentRef] = []
for raw in step.get("selectors") or ():
if not isinstance(raw, Mapping):
continue
kind = str(raw.get("kind") or "").strip()
assignment_id = str(raw.get("value") or "").strip()
if kind == "any_account":
kind, assignment_id = "anyone", "*"
if kind not in {"account", "group", "role", "function_assignment"}:
if kind != "anyone":
continue
label = str(raw.get("label") or "").strip()[:500] or None
try:
assignments.append(
WorkAssignmentRef(
kind=kind, # type: ignore[arg-type]
id=assignment_id,
label=label,
)
)
except ValueError:
continue
return tuple(assignments)
def _date(value: object) -> datetime | None:
if isinstance(value, datetime):
return value
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _search_text(item: WorkItem) -> str:
return " ".join(
value for value in (item.title, item.summary, item.required_action) if value
).casefold()
def _sort_key(item: WorkItem) -> tuple[object, ...]:
priority = {"urgent": 0, "high": 1, "normal": 2, "low": 3}[item.priority]
due_at = _aware(item.due_at) if item.due_at else datetime.max.replace(tzinfo=UTC)
return priority, due_at, item.id
__all__ = ["ApprovalWorkItemProvider", "PROVIDER_ID"]