diff --git a/docs/APPROVALS_DOMAIN_BOUNDARY.md b/docs/APPROVALS_DOMAIN_BOUNDARY.md index c2ce0df..65b2142 100644 --- a/docs/APPROVALS_DOMAIN_BOUNDARY.md +++ b/docs/APPROVALS_DOMAIN_BOUNDARY.md @@ -26,6 +26,12 @@ signature reference; Approvals does not implement document signing or key custody. A fail-fast rejection ends the request. Due steps can enter an explicit escalated state without silently changing their outcome. +When Tasks is enabled, Approvals projects only currently actionable steps into +the common work inbox. The projection applies the same selector, expiration, +prior-decision, unique-actor, evidence-role, and requester-separation checks as +the decision command. Approvals remains the owner of decision and completion +state; Tasks receives no copied approval record. + ## Recovery and scale-out All API and worker nodes use the logically shared database. Back up and restore @@ -44,7 +50,8 @@ database and reconcile every module object that retains an Approval reference. ## Optional integrations -Workflow Engine may wait for completion and Notifications may announce an -assignment, due date, escalation, or outcome. Audit may retain additional -cross-domain evidence. Policy may provide chain templates. These integrations -use capabilities and events; none reads Approval tables directly. +Workflow Engine may wait for completion, Tasks may aggregate actionable work, +and Notifications may announce an assignment, due date, escalation, or +outcome. Audit may retain additional cross-domain evidence. Policy may provide +chain templates. These integrations use capabilities and events; none reads +Approval tables directly. diff --git a/src/govoplan_approvals/backend/manifest.py b/src/govoplan_approvals/backend/manifest.py index 76bc418..bcde983 100644 --- a/src/govoplan_approvals/backend/manifest.py +++ b/src/govoplan_approvals/backend/manifest.py @@ -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": [ diff --git a/src/govoplan_approvals/backend/service.py b/src/govoplan_approvals/backend/service.py index b06da88..bb7c219 100644 --- a/src/govoplan_approvals/backend/service.py +++ b/src/govoplan_approvals/backend/service.py @@ -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", +] diff --git a/src/govoplan_approvals/backend/work_items.py b/src/govoplan_approvals/backend/work_items.py new file mode 100644 index 0000000..2cd7b21 --- /dev/null +++ b/src/govoplan_approvals/backend/work_items.py @@ -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"] diff --git a/tests/test_approvals.py b/tests/test_approvals.py index dc4b5f7..5c5a085 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -7,6 +7,8 @@ import unittest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef from govoplan_core.core.approvals import ( ApprovalActorSelector, ApprovalDecisionCommand, @@ -16,6 +18,8 @@ from govoplan_core.core.approvals import ( ) from govoplan_core.db.base import Base from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests +from govoplan_core.core.tasks import WorkItemQuery +from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider DIGEST = "a" * 64 @@ -241,6 +245,62 @@ class ApprovalRuntimeTests(unittest.TestCase): ) self.assertEqual("escalated", escalated.state) + def test_work_provider_reuses_exact_decision_eligibility(self) -> None: + with self.Session() as session: + created = self.service.create_request( + session, + self.requester, + command=request_command(), + idempotency_key="work-provider-request", + ) + reviewer = ApiPrincipal( + principal=PrincipalRef( + account_id="reviewer", + membership_id="membership-reviewer", + tenant_id="tenant-1", + scopes=frozenset( + { + "approvals:workspace:read", + "approvals:workspace:decide", + } + ), + group_ids=frozenset({"reviewers"}), + ), + account=object(), + user=object(), + ) + provider = ApprovalWorkItemProvider() + + page = provider.list_items( + session, + reviewer, + query=WorkItemQuery(tenant_id="tenant-1"), + ) + self.assertEqual(1, page.total) + self.assertEqual(created.id, page.items[0].id) + self.assertEqual("Review", page.items[0].required_action) + self.assertEqual("campaign_version", page.items[0].sources[0].resource_type) + + self.service.decide( + session, + reviewer, + request_id=created.id, + command=ApprovalDecisionCommand( + "approved", + "Reviewed.", + 1, + "work-provider-decision", + ), + ) + self.assertEqual( + 0, + provider.list_items( + session, + reviewer, + query=WorkItemQuery(tenant_id="tenant-1"), + ).total, + ) + def test_template_and_evidence_role_constraints_are_frozen(self) -> None: template_command = ApprovalTemplateCreateCommand( key="campaign-release", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 8b964f7..73e185c 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -26,7 +26,9 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase): templates = topics["approvals.workflow.administer-templates"] self.assertIn("approvals.workspace", guide.metadata["help_contexts"]) self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) - self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"]) + self.assertIn( + "approvals.field.subject-digest", reference.metadata["help_contexts"] + ) self.assertIn("create_request", reference.metadata["consequence_classes"]) self.assertIn("reject_request", reference.metadata["consequence_classes"]) self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"]) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d481c3e..89103f1 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -30,6 +30,7 @@ class ManifestTests(unittest.TestCase): self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.frontend) + self.assertEqual("approvals.pending", manifest.work_item_providers[0].id) if __name__ == "__main__": diff --git a/webui/src/module.ts b/webui/src/module.ts index c65f4af..cce2435 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -28,9 +28,9 @@ const approvalsAdminSections: AdminSectionsUiCapability = { export const approvalsModule: PlatformWebModule = { id: "approvals", label: "i18n:govoplan-approvals.approvals", - version: "0.1.14", + version: "0.1.18", dependencies: ["access"], - optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"], + optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy", "tasks"], translations: generatedTranslations, routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }], navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],