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
+11 -4
View File
@@ -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 custody. A fail-fast rejection ends the request. Due steps can enter an
explicit escalated state without silently changing their outcome. 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 ## Recovery and scale-out
All API and worker nodes use the logically shared database. Back up and restore 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 ## Optional integrations
Workflow Engine may wait for completion and Notifications may announce an Workflow Engine may wait for completion, Tasks may aggregate actionable work,
assignment, due date, escalation, or outcome. Audit may retain additional and Notifications may announce an assignment, due date, escalation, or
cross-domain evidence. Policy may provide chain templates. These integrations outcome. Audit may retain additional cross-domain evidence. Policy may provide
use capabilities and events; none reads Approval tables directly. chain templates. These integrations use capabilities and events; none reads
Approval tables directly.
+42 -6
View File
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture 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.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_approvals.backend.db import models as approval_models from govoplan_approvals.backend.db import models as approval_models
@@ -39,7 +40,14 @@ READ_SCOPE = "approvals:workspace:read"
WRITE_SCOPE = "approvals:workspace:write" WRITE_SCOPE = "approvals:workspace:write"
DECIDE_SCOPE = "approvals:workspace:decide" DECIDE_SCOPE = "approvals:workspace:decide"
ADMIN_SCOPE = "approvals:workspace:admin" 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: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -66,6 +74,12 @@ def _requests(_context: ModuleContext) -> SqlApprovalRequests:
return 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]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(approval_models.ApprovalRequestRevision).filter( current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id, approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
@@ -196,6 +210,13 @@ manifest = ModuleManifest(
contract_version="0.1.0", contract_version="0.1.0",
) )
}, },
work_item_providers=(
WorkItemProviderRegistration(
id="approvals.pending",
factory=_work_items,
order=30,
),
),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
metadata=Base.metadata, metadata=Base.metadata,
@@ -229,7 +250,8 @@ manifest = ModuleManifest(
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.", summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
body=( body=(
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. " "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", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -312,10 +334,24 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner", "auditor"), audience=("operator", "module_admin", "product_owner", "auditor"),
links=( links=(
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"), DocumentationLink(
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"), label="Approval templates",
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"), href="/admin?section=tenant-approval-templates",
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"), 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={ metadata={
"help_contexts": [ "help_contexts": [
+98 -54
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from dataclasses import asdict from dataclasses import asdict, dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
import hashlib import hashlib
import json import json
@@ -33,6 +33,13 @@ class ApprovalStoreError(ValueError):
pass pass
@dataclass(frozen=True, slots=True)
class ApprovalDecisionContext:
step: Mapping[str, Any]
effective_actor: str
matched_selector: Mapping[str, object]
class SqlApprovalRequests: class SqlApprovalRequests:
def create_template( def create_template(
self, self,
@@ -278,9 +285,7 @@ class SqlApprovalRequests:
.filter( .filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal), ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id, ApprovalTemplateRevision.template_id == template_id,
ApprovalTemplateRevision.revision.in_( ApprovalTemplateRevision.revision.in_((from_revision, to_revision)),
(from_revision, to_revision)
),
) )
.all() .all()
) )
@@ -480,56 +485,17 @@ class SqlApprovalRequests:
raise ApprovalStoreError( raise ApprovalStoreError(
"This Approval request no longer accepts decisions." "This Approval request no longer accepts decisions."
) )
expires_at = _datetime(current.payload.get("expires_at")) decision_context = approval_decision_context(
if expires_at is not None and _now() >= expires_at: typed_session,
raise ApprovalStoreError("This Approval request has expired.") principal,
current,
delegated_for_account_id=command.delegated_for_account_id,
)
steps = list(current.payload["steps"]) steps = list(current.payload["steps"])
step_index = int(current.payload.get("current_step_index") or 0) step_index = int(current.payload.get("current_step_index") or 0)
step = steps[step_index] step = decision_context.step
effective_actor = _effective_actor(principal, command.delegated_for_account_id) effective_actor = decision_context.effective_actor
matched_selector = _matched_selector( matched_selector = decision_context.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."
)
signature_ref = ( signature_ref = (
dict(command.signature_ref) if command.signature_ref is not None else None 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]: def _step_payload(step: object) -> dict[str, Any]:
return { return {
"key": str(getattr(step, "key")), "key": str(getattr(step, "key")),
@@ -1383,4 +1422,9 @@ def _now() -> datetime:
return datetime.now(UTC) 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"]
+60
View File
@@ -7,6 +7,8 @@ import unittest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.approvals import ( from govoplan_core.core.approvals import (
ApprovalActorSelector, ApprovalActorSelector,
ApprovalDecisionCommand, ApprovalDecisionCommand,
@@ -16,6 +18,8 @@ from govoplan_core.core.approvals import (
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests 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 DIGEST = "a" * 64
@@ -241,6 +245,62 @@ class ApprovalRuntimeTests(unittest.TestCase):
) )
self.assertEqual("escalated", escalated.state) 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: def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
template_command = ApprovalTemplateCreateCommand( template_command = ApprovalTemplateCreateCommand(
key="campaign-release", key="campaign-release",
@@ -26,7 +26,9 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
templates = topics["approvals.workflow.administer-templates"] templates = topics["approvals.workflow.administer-templates"]
self.assertIn("approvals.workspace", guide.metadata["help_contexts"]) self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) 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("create_request", reference.metadata["consequence_classes"])
self.assertIn("reject_request", reference.metadata["consequence_classes"]) self.assertIn("reject_request", reference.metadata["consequence_classes"])
self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"]) self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"])
+1
View File
@@ -30,6 +30,7 @@ class ManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend) self.assertIsNotNone(manifest.frontend)
self.assertEqual("approvals.pending", manifest.work_item_providers[0].id)
if __name__ == "__main__": if __name__ == "__main__":
+2 -2
View File
@@ -28,9 +28,9 @@ const approvalsAdminSections: AdminSectionsUiCapability = {
export const approvalsModule: PlatformWebModule = { export const approvalsModule: PlatformWebModule = {
id: "approvals", id: "approvals",
label: "i18n:govoplan-approvals.approvals", label: "i18n:govoplan-approvals.approvals",
version: "0.1.14", version: "0.1.18",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"], optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy", "tasks"],
translations: generatedTranslations, translations: generatedTranslations,
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }], 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" }], navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],