Project pending approvals as authorized work
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user