Implement generic approval runtime

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 2b081cb931
commit 62d0f300cb
23 changed files with 3133 additions and 118 deletions
+4 -6
View File
@@ -4,9 +4,7 @@
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-approvals` is the GovOPlaN platform module seed for generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
`govoplan-approvals` owns generic approval and sign-off chains with trusted delegation, separation of duties, escalation, and signature references. It persists immutable request revisions, append-only decisions and lifecycle evidence, exposes a tenant-scoped API and WebUI, and provides the `approvals.requests` capability for exact-subject approval checks.
## Initial Ownership
@@ -29,9 +27,9 @@ Detailed boundary notes are in [docs/APPROVALS_DOMAIN_BOUNDARY.md](docs/APPROVAL
## Integrations
Expected optional integrations:
Optional integrations:
- workflow
- workflow engine
- audit
- files
- notifications
@@ -45,7 +43,7 @@ cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-approvals
```
Focused manifest verification:
Focused runtime verification:
```bash
cd /mnt/DATA/git/govoplan-approvals
+39 -33
View File
@@ -1,44 +1,50 @@
# Approvals Domain Boundary
# Approvals domain boundary and operations
## Purpose
Approvals owns generic, reusable sign-off chains. A request freezes an exact
module-owned subject revision, ordered steps, eligible actor selectors,
required counts, rejection behavior, due dates, signature requirements,
separation-of-duties rules, and policy references.
Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.
Approvals does not own the subject's business state. A Campaign remains a
Campaign and a Decision remains a Decision. Consumers call
`approvals.requests.check_approved` with the exact subject identity and version
before performing their consequential transition. A previously approved
request cannot authorize a changed subject revision.
## Owns
## Actor and decision semantics
- approval requests
- sign-off chains
- delegation and substitution facts
- four-eyes constraints
- escalation state
- signature references
Selectors can target an account, group, role, function assignment, or any
authenticated account. They are evaluated against trusted principal claims.
Delegated decisions are accepted only when the principal already carries the
matching acting-for account. The actual and represented account plus the
trusted delegation identifier are retained.
## Does Not Own
Requests can prohibit requester self-approval and can require distinct actors
across steps. Each decision is append-only, reasoned, optimistic-concurrency
protected, and idempotent. Signature-required steps store a provider-owned
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.
- module-specific business decisions
- workflow orchestration engine
- identity and RBAC primitives
## Recovery and scale-out
## Integration Candidates
All API and worker nodes use the logically shared database. Back up and restore
these tables as one consistency unit:
- workflow
- audit
- files
- notifications
- `approval_request_revisions`
- `approval_decision_records`
- `approval_lifecycle_events`
- `approval_replays`
## Seed State
After restore, verify one current revision per tenant/request, contiguous event
sequences, unique actor decisions per step, decision receipt hashes, and exact
subject bindings. Consumers must re-run `check_approved`; they must not infer
approval from cached UI state. Before destructive retirement, snapshot the
database and reconcile every module object that retains an Approval reference.
The current repository state is intentionally small:
## Optional integrations
- module manifest and entry point
- tenant-level permission definitions
- manager and viewer role templates
- documentation topic describing the module boundary
- Gitea issue workflow templates
- manifest contract test
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
## First Implementation Slice
Define reusable approval request, step, actor, delegation, substitution, and decision result contracts for consuming modules.
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.
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@govoplan/approvals",
"version": "0.1.8",
"version": "0.1.14",
"private": true,
"description": "GovOPlaN Approvals platform module seed.",
"description": "Governed approval chains, decisions, delegation, and escalation for GovOPlaN.",
"type": "module",
"peerDependencies": {}
}
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-approvals"
version = "0.1.8"
description = "GovOPlaN Approvals platform module seed."
version = "0.1.14"
description = "Governed approval chains, decisions, delegation, and escalation for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.14",
"govoplan-access>=0.1.8",
]
@@ -0,0 +1,13 @@
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalReplay,
)
__all__ = [
"ApprovalDecisionRecord",
"ApprovalLifecycleEvent",
"ApprovalReplay",
"ApprovalRequestRevision",
]
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class ApprovalRequestRevision(Base, TimestampMixin):
__tablename__ = "approval_request_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "request_id", "revision", name="uq_approval_request_revision"
),
Index(
"ix_approval_request_current", "tenant_id", "request_id", "superseded_at"
),
Index(
"ix_approval_request_subject",
"tenant_id",
"subject_module",
"subject_type",
"subject_id",
"state",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("approval_request_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
current_step_key: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
subject_module: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
subject_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject_version: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
subject_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class ApprovalDecisionRecord(Base, TimestampMixin):
__tablename__ = "approval_decision_records"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"request_id",
"step_key",
"effective_actor_id",
name="uq_approval_step_actor",
),
UniqueConstraint(
"tenant_id",
"request_id",
"idempotency_key",
name="uq_approval_decision_replay",
),
Index("ix_approval_decision_history", "tenant_id", "request_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_revision: Mapped[int] = mapped_column(Integer, nullable=False)
step_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
outcome: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
reason: Mapped[str] = mapped_column(String(4000), nullable=False)
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
effective_actor_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
authority_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, nullable=False, default=dict
)
signature_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
class ApprovalLifecycleEvent(Base, TimestampMixin):
__tablename__ = "approval_lifecycle_events"
__table_args__ = (
UniqueConstraint(
"tenant_id", "request_id", "sequence", name="uq_approval_event_sequence"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
class ApprovalReplay(Base, TimestampMixin):
__tablename__ = "approval_replays"
__table_args__ = (
UniqueConstraint(
"tenant_id", "operation", "idempotency_key", name="uq_approval_replay"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
operation: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
class ApprovalTemplateRevision(Base, TimestampMixin):
__tablename__ = "approval_template_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "template_id", "revision", name="uq_approval_template_revision"
),
UniqueConstraint(
"tenant_id", "key", "revision", name="uq_approval_template_key_revision"
),
Index(
"ix_approval_template_current", "tenant_id", "template_id", "superseded_at"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
template_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("approval_template_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
__all__ = [
"ApprovalDecisionRecord",
"ApprovalLifecycleEvent",
"ApprovalReplay",
"ApprovalRequestRevision",
"ApprovalTemplateRevision",
]
+228 -66
View File
@@ -1,21 +1,45 @@
from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.approvals import CAPABILITY_APPROVAL_REQUESTS
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_approvals.backend.db import models as approval_models
from govoplan_approvals.backend.service import SqlApprovalRequests
MODULE_ID = "approvals"
MODULE_NAME = "Approvals"
MODULE_VERSION = "0.1.8"
MODULE_VERSION = "0.1.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",
)
OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy")
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -24,7 +48,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
scope=scope,
label=label,
description=description,
category="Approvals",
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
@@ -32,56 +56,28 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
)
PERMISSIONS = (
_permission(READ_SCOPE, "View approvals workspace", "Read approvals records, configuration, and workflow context."),
_permission(WRITE_SCOPE, "Manage approvals workspace", "Create and update approvals records and workflow state."),
_permission(ADMIN_SCOPE, "Administer approvals workspace", "Configure approvals policies, templates, and tenant-level administration."),
)
def _router(_context: ModuleContext):
from govoplan_approvals.backend.router import router
ROLE_TEMPLATES = (
RoleTemplate(
slug="approvals_manager",
name="Approvals manager",
description="Manage approvals records and workflow state.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="approvals_viewer",
name="Approvals viewer",
description="Read approvals records and workflow context.",
permissions=(READ_SCOPE,),
),
)
return router
def _requests(_context: ModuleContext) -> SqlApprovalRequests:
return SqlApprovalRequests()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
approval_models.ApprovalRequestRevision.superseded_at.is_(None),
)
return {
"approval_requests": current.count(),
"approval_pending": current.filter(
approval_models.ApprovalRequestRevision.state.in_(("pending", "escalated"))
).count(),
}
DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary",
summary="Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.",
body=(
"This repository is currently a platform module seed. It registers the domain boundary, "
"permission surface, role templates, and documentation metadata before runtime APIs, "
"database models, migrations, and WebUI routes are introduced."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
order=100,
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Repository domain boundary",
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"seed": True,
"domain_objects": ['approval requests', 'sign-off chains', 'delegation and substitution facts', 'four-eyes constraints', 'escalation state', 'signature references'],
"first_slice": "Define reusable approval request, step, actor, delegation, substitution, and decision result contracts for consuming modules.",
},
),
)
manifest = ModuleManifest(
id=MODULE_ID,
@@ -89,18 +85,184 @@ manifest = ModuleManifest(
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION,
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_APPROVAL_REQUESTS, version="0.1.0"),
),
permissions=(
_permission(
READ_SCOPE,
"View approval requests",
"Read approval chains, current gates, outcomes, and history.",
),
_permission(
WRITE_SCOPE,
"Request approvals",
"Create immutable approval chains for exact subject revisions.",
),
_permission(
DECIDE_SCOPE,
"Decide approvals",
"Approve or reject eligible approval steps.",
),
_permission(
ADMIN_SCOPE,
"Administer approvals",
"Escalate due approvals and configure approval policies.",
),
),
role_templates=(
RoleTemplate(
slug="approvals_manager",
name="Approvals manager",
description="Create and manage approval requests.",
permissions=(READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE),
),
RoleTemplate(
slug="approver",
name="Approver",
description="Read and decide eligible approval steps.",
permissions=(READ_SCOPE, DECIDE_SCOPE),
),
RoleTemplate(
slug="approvals_admin",
name="Approvals administrator",
description="Administer approval policies and escalation.",
permissions=(READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE, ADMIN_SCOPE),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/approvals",
label="Approvals",
icon="list-checks",
required_any=(READ_SCOPE,),
order=37,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/approvals-webui",
routes=(
FrontendRoute(
path="/approvals",
component="ApprovalsPage",
required_any=(READ_SCOPE,),
order=37,
),
),
nav_items=(
NavItem(
path="/approvals",
label="Approvals",
icon="list-checks",
required_any=(READ_SCOPE,),
order=37,
),
),
view_surfaces=(
ViewSurface(
id="approvals.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Approvals navigation",
order=10,
),
ViewSurface(
id="approvals.workspace",
module_id=MODULE_ID,
kind="route",
label="Approval request workspace",
order=20,
),
),
),
capability_factories={CAPABILITY_APPROVAL_REQUESTS: _requests},
capability_documentation={
CAPABILITY_APPROVAL_REQUESTS: CapabilityDocumentation(
label="Governed approval requests",
summary="Freezes exact subject approval chains and resolves auditable sequential decisions.",
contract_version="0.1.0",
)
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
approval_models.ApprovalReplay,
approval_models.ApprovalLifecycleEvent,
approval_models.ApprovalDecisionRecord,
approval_models.ApprovalRequestRevision,
approval_models.ApprovalTemplateRevision,
label=MODULE_NAME,
),
retirement_notes="Destructive retirement requires a verified snapshot and removes approval chains, decisions, signature references, and lifecycle evidence.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
approval_models.ApprovalRequestRevision,
approval_models.ApprovalDecisionRecord,
approval_models.ApprovalLifecycleEvent,
approval_models.ApprovalReplay,
approval_models.ApprovalTemplateRevision,
label=MODULE_NAME,
),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="approvals.module-boundary",
title="Governed approval chains",
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."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Approvals boundary and recovery",
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="governance",
maturity="scaffold",
maturity="vertical_slice",
documentation_ref="docs/APPROVALS_DOMAIN_BOUNDARY.md",
known_limits=("Runtime approval lifecycles and persistence are not implemented yet.",),
owned_concepts=("approval request", "approval chain", "approval decision"),
non_owned_concepts=("workflow execution", "identity", "document signature"),
test_ref="tests/test_approvals.py",
known_limits=(
"Policy-authored template selection and cryptographic signature providers remain optional product depth; signature references are evidence pointers, not a cryptographic claim.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
"approval request",
"approval chain",
"approval decision",
"approval escalation",
),
non_owned_concepts=(
"workflow execution",
"identity",
"document signature",
"module business outcome",
),
migration_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
recovery_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
security_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
operations_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
),
)
@@ -0,0 +1 @@
"""Approvals migrations."""
@@ -0,0 +1,254 @@
"""v0.1.14 Approvals runtime.
Revision ID: 8b9c0d1e2f3a
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "8b9c0d1e2f3a"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"approval_request_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("current_step_key", sa.String(length=120), nullable=True),
sa.Column("subject_module", sa.String(length=120), nullable=False),
sa.Column("subject_type", sa.String(length=120), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("subject_version", sa.String(length=120), nullable=True),
sa.Column("subject_digest", sa.String(length=64), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["approval_request_revisions.id"],
name=op.f(
"fk_approval_request_revisions_previous_revision_id_approval_request_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_request_revisions")),
sa.UniqueConstraint(
"tenant_id", "request_id", "revision", name="uq_approval_request_revision"
),
)
for column in (
"tenant_id",
"request_id",
"previous_revision_id",
"state",
"current_step_key",
"subject_module",
"subject_type",
"subject_id",
"subject_version",
"subject_digest",
"recorded_at",
"superseded_at",
"actor_id",
):
op.create_index(
op.f(f"ix_approval_request_revisions_{column}"),
"approval_request_revisions",
[column],
unique=False,
)
op.create_index(
"ix_approval_request_current",
"approval_request_revisions",
["tenant_id", "request_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_approval_request_subject",
"approval_request_revisions",
["tenant_id", "subject_module", "subject_type", "subject_id", "state"],
unique=False,
)
op.create_table(
"approval_decision_records",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("request_revision", sa.Integer(), nullable=False),
sa.Column("step_key", sa.String(length=120), nullable=False),
sa.Column("outcome", sa.String(length=20), nullable=False),
sa.Column("reason", sa.String(length=4000), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=False),
sa.Column("effective_actor_id", sa.String(length=255), nullable=False),
sa.Column("delegation_id", sa.String(length=255), nullable=True),
sa.Column("authority_provenance", sa.JSON(), nullable=False),
sa.Column("signature_ref", sa.JSON(), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("receipt_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_decision_records")),
sa.UniqueConstraint(
"tenant_id",
"request_id",
"step_key",
"effective_actor_id",
name="uq_approval_step_actor",
),
sa.UniqueConstraint(
"tenant_id",
"request_id",
"idempotency_key",
name="uq_approval_decision_replay",
),
)
for column in (
"tenant_id",
"request_id",
"step_key",
"outcome",
"actor_id",
"effective_actor_id",
"recorded_at",
"receipt_sha256",
):
op.create_index(
op.f(f"ix_approval_decision_records_{column}"),
"approval_decision_records",
[column],
unique=False,
)
op.create_index(
"ix_approval_decision_history",
"approval_decision_records",
["tenant_id", "request_id", "recorded_at"],
unique=False,
)
op.create_table(
"approval_lifecycle_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=60), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_lifecycle_events")),
sa.UniqueConstraint(
"tenant_id", "request_id", "sequence", name="uq_approval_event_sequence"
),
)
for column in ("tenant_id", "request_id", "event_type", "recorded_at"):
op.create_index(
op.f(f"ix_approval_lifecycle_events_{column}"),
"approval_lifecycle_events",
[column],
unique=False,
)
op.create_table(
"approval_replays",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("operation", sa.String(length=80), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("response", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_replays")),
sa.UniqueConstraint(
"tenant_id", "operation", "idempotency_key", name="uq_approval_replay"
),
)
for column in ("tenant_id", "operation"):
op.create_index(
op.f(f"ix_approval_replays_{column}"),
"approval_replays",
[column],
unique=False,
)
op.create_table(
"approval_template_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=False),
sa.Column("key", sa.String(length=120), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("content_sha256", sa.String(length=64), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["approval_template_revisions.id"],
name=op.f(
"fk_approval_template_revisions_previous_revision_id_approval_template_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_template_revisions")),
sa.UniqueConstraint(
"tenant_id", "template_id", "revision", name="uq_approval_template_revision"
),
sa.UniqueConstraint(
"tenant_id", "key", "revision", name="uq_approval_template_key_revision"
),
)
for column in (
"tenant_id",
"template_id",
"key",
"previous_revision_id",
"state",
"content_sha256",
"recorded_at",
"superseded_at",
"actor_id",
):
op.create_index(
op.f(f"ix_approval_template_revisions_{column}"),
"approval_template_revisions",
[column],
unique=False,
)
op.create_index(
"ix_approval_template_current",
"approval_template_revisions",
["tenant_id", "template_id", "superseded_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("approval_template_revisions")
op.drop_table("approval_replays")
op.drop_table("approval_lifecycle_events")
op.drop_table("approval_decision_records")
op.drop_table("approval_request_revisions")
@@ -0,0 +1 @@
"""Approvals migration revisions."""
+305
View File
@@ -0,0 +1,305 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_approvals.backend.schemas import (
ApprovalCreateRequest,
ApprovalDecisionInput,
ApprovalListResponse,
ApprovalTemplateCreateRequest,
ApprovalTemplateReviseRequest,
ApprovalTransitionInput,
)
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
router = APIRouter(prefix="/approvals", tags=["approvals"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, LookupError):
return HTTPException(status_code=404, detail=str(exc))
text = str(exc)
return HTTPException(
status_code=409
if "conflict" in text.lower() or "idempotency" in text.lower()
else 400,
detail=text,
)
@router.get("", response_model=ApprovalListResponse)
def api_list_requests(
request_state: str | None = Query(default=None, alias="state"),
subject_module: str | None = None,
subject_id: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ApprovalListResponse:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
return ApprovalListResponse(
requests=list(
SqlApprovalRequests().list_requests(
session,
principal,
state=request_state,
subject_module=subject_module,
subject_id=subject_id,
limit=limit,
)
)
)
@router.get("/templates", response_model=list[dict[str, Any]])
def api_list_templates(
template_state: str | None = Query(default=None, alias="state"),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[dict[str, Any]]:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
return [
dict(item)
for item in SqlApprovalRequests().list_templates(
session, principal, state=template_state, limit=limit
)
]
@router.post(
"/templates", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED
)
def api_create_template(
payload: ApprovalTemplateCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
result = SqlApprovalRequests().create_template(
session,
principal,
command=payload.template.to_command(),
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_template(
session, principal, template_id=result.id
)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.put("/templates/{template_id}", response_model=dict[str, Any])
def api_revise_template(
template_id: str,
payload: ApprovalTemplateReviseRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
result = SqlApprovalRequests().revise_template(
session,
principal,
template_id=template_id,
command=payload.template.to_command(),
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_template(
session, principal, template_id=result.id
)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/templates/{template_id}/publish", response_model=dict[str, Any])
def api_publish_template(
template_id: str,
payload: ApprovalTransitionInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
result = SqlApprovalRequests().publish_template(
session,
principal,
template_id=template_id,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_template(
session, principal, template_id=result.id
)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.get("/{request_id}", response_model=dict[str, Any])
def api_get_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
item = SqlApprovalRequests().get_request(session, principal, request_id=request_id)
if item is None:
raise HTTPException(status_code=404, detail="Approval request not found")
return dict(item)
@router.get("/{request_id}/history", response_model=list[dict[str, Any]])
def api_get_history(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[dict[str, Any]]:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
if (
SqlApprovalRequests().get_request(session, principal, request_id=request_id)
is None
):
raise HTTPException(status_code=404, detail="Approval request not found")
return [
dict(item)
for item in SqlApprovalRequests().history(
session, principal, request_id=request_id
)
]
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
def api_create_request(
payload: ApprovalCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import WRITE_SCOPE
_require(principal, WRITE_SCOPE)
try:
result = SqlApprovalRequests().create_request(
session,
principal,
command=payload.request.to_command(),
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_request(session, principal, request_id=result.id)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{request_id}/decisions", response_model=dict[str, Any])
def api_decide(
request_id: str,
payload: ApprovalDecisionInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import DECIDE_SCOPE
_require(principal, DECIDE_SCOPE)
try:
receipt = SqlApprovalRequests().decide(
session, principal, request_id=request_id, command=payload.to_command()
)
session.commit()
return {
"receipt": {
"request_id": receipt.request_id,
"revision": receipt.revision,
"step_key": receipt.step_key,
"outcome": receipt.outcome,
"actor_id": receipt.actor_id,
"recorded_at": receipt.recorded_at,
"receipt_sha256": receipt.receipt_sha256,
"replayed": receipt.replayed,
},
"request": dict(
SqlApprovalRequests().get_request(
session, principal, request_id=request_id
)
or {}
),
}
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{request_id}/escalate", response_model=dict[str, Any])
def api_escalate(
request_id: str,
payload: ApprovalTransitionInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
SqlApprovalRequests().escalate_due(
session,
principal,
request_id=request_id,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_request(session, principal, request_id=request_id)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
__all__ = ["router"]
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalDecisionCommand,
ApprovalRequestCreateCommand,
ApprovalStepDefinition,
ApprovalTemplateCreateCommand,
)
class ApprovalSelectorInput(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["account", "group", "role", "function_assignment", "any_account"]
value: str = Field(min_length=1, max_length=255)
label: str | None = Field(default=None, max_length=255)
class ApprovalStepInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
selectors: list[ApprovalSelectorInput] = Field(min_length=1, max_length=500)
required_approvals: int = Field(default=1, ge=1, le=500)
rejection_policy: Literal["fail_fast", "collect"] = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: list[str] = Field(default_factory=list, max_length=100)
metadata: dict[str, Any] = Field(default_factory=dict)
def to_definition(self) -> ApprovalStepDefinition:
return ApprovalStepDefinition(
key=self.key,
label=self.label,
selectors=tuple(
ApprovalActorSelector(item.kind, item.value, item.label)
for item in self.selectors
),
required_approvals=self.required_approvals,
rejection_policy=self.rejection_policy,
due_at=self.due_at,
signature_required=self.signature_required,
forbidden_evidence_roles=tuple(self.forbidden_evidence_roles),
metadata=self.metadata,
)
class ApprovalRequestInput(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10_000)
subject_module: str = Field(min_length=1, max_length=120)
subject_type: str = Field(min_length=1, max_length=120)
subject_id: str = Field(min_length=1, max_length=255)
subject_version: str | None = Field(default=None, max_length=120)
subject_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
steps: list[ApprovalStepInput] = Field(default_factory=list, max_length=100)
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
expires_at: datetime | None = None
policy_refs: list[str] = Field(default_factory=list, max_length=500)
evidence_actors: dict[str, list[str]] = Field(default_factory=dict)
template_id: str | None = Field(default=None, max_length=36)
template_revision: int | None = Field(default=None, ge=1)
metadata: dict[str, Any] = Field(default_factory=dict)
def to_command(self) -> ApprovalRequestCreateCommand:
return ApprovalRequestCreateCommand(
title=self.title,
description=self.description,
subject_module=self.subject_module,
subject_type=self.subject_type,
subject_id=self.subject_id,
subject_version=self.subject_version,
subject_digest=self.subject_digest,
steps=tuple(step.to_definition() for step in self.steps),
separation_of_duties=self.separation_of_duties,
unique_actors_across_steps=self.unique_actors_across_steps,
expires_at=self.expires_at,
policy_refs=tuple(self.policy_refs),
evidence_actors={
key: tuple(values) for key, values in self.evidence_actors.items()
},
template_id=self.template_id,
template_revision=self.template_revision,
metadata=self.metadata,
)
class ApprovalTemplateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10_000)
steps: list[ApprovalStepInput] = Field(min_length=1, max_length=100)
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
def to_command(self) -> ApprovalTemplateCreateCommand:
return ApprovalTemplateCreateCommand(
key=self.key,
title=self.title,
description=self.description,
steps=tuple(step.to_definition() for step in self.steps),
separation_of_duties=self.separation_of_duties,
unique_actors_across_steps=self.unique_actors_across_steps,
metadata=self.metadata,
)
class ApprovalTemplateCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
template: ApprovalTemplateInput
idempotency_key: str = Field(min_length=1, max_length=160)
class ApprovalTemplateReviseRequest(ApprovalTemplateCreateRequest):
expected_revision: int = Field(ge=1)
class ApprovalCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
request: ApprovalRequestInput
idempotency_key: str = Field(min_length=1, max_length=160)
class ApprovalDecisionInput(BaseModel):
model_config = ConfigDict(extra="forbid")
outcome: Literal["approved", "rejected"]
reason: str = Field(min_length=1, max_length=4000)
expected_revision: int = Field(ge=1)
idempotency_key: str = Field(min_length=1, max_length=160)
delegated_for_account_id: str | None = Field(default=None, max_length=255)
signature_ref: dict[str, Any] | None = None
def to_command(self) -> ApprovalDecisionCommand:
return ApprovalDecisionCommand(
outcome=self.outcome,
reason=self.reason,
expected_revision=self.expected_revision,
idempotency_key=self.idempotency_key,
delegated_for_account_id=self.delegated_for_account_id,
signature_ref=self.signature_ref,
)
class ApprovalTransitionInput(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
idempotency_key: str = Field(min_length=1, max_length=160)
class ApprovalListResponse(BaseModel):
requests: list[dict[str, Any]]
__all__ = [
"ApprovalCreateRequest",
"ApprovalDecisionInput",
"ApprovalListResponse",
"ApprovalTransitionInput",
"ApprovalTemplateCreateRequest",
"ApprovalTemplateReviseRequest",
]
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalDecisionCommand,
ApprovalRequestCreateCommand,
ApprovalStepDefinition,
ApprovalTemplateCreateCommand,
)
from govoplan_core.db.base import Base
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
DIGEST = "a" * 64
@dataclass
class Principal:
tenant_id: str
account_id: str
group_ids: tuple[str, ...] = ()
role_ids: tuple[str, ...] = ()
function_assignment_ids: tuple[str, ...] = ()
acting_for_account_id: str | None = None
acting_assignment_id: str | None = None
def request_command() -> ApprovalRequestCreateCommand:
return ApprovalRequestCreateCommand(
title="Approve Campaign delivery",
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-7",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
key="review",
label="Review",
selectors=(ApprovalActorSelector("group", "reviewers"),),
),
ApprovalStepDefinition(
key="release",
label="Release",
selectors=(ApprovalActorSelector("role", "senders"),),
signature_required=True,
),
),
separation_of_duties=True,
unique_actors_across_steps=True,
)
class ApprovalRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
self.service = SqlApprovalRequests()
self.requester = Principal("tenant-1", "requester")
def tearDown(self) -> None:
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_sequential_chain_exact_subject_and_signature(self) -> None:
with self.Session() as session:
created = self.service.create_request(
session,
self.requester,
command=request_command(),
idempotency_key="create-1",
)
review = self.service.decide(
session,
Principal("tenant-1", "reviewer", group_ids=("reviewers",)),
request_id=created.id,
command=ApprovalDecisionCommand("approved", "Reviewed.", 1, "review-1"),
)
self.assertEqual(2, review.revision)
current = self.service.get_request(
session, self.requester, request_id=created.id
)
self.assertEqual("release", current["current_step_key"])
with self.assertRaisesRegex(ApprovalStoreError, "signature"):
self.service.decide(
session,
Principal("tenant-1", "sender", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved", "Release.", 2, "release-no-signature"
),
)
released = self.service.decide(
session,
Principal("tenant-1", "sender", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved",
"Release.",
2,
"release-1",
signature_ref={"provider": "signatures", "id": "sig-1"},
),
)
self.assertEqual(3, released.revision)
check = self.service.check_approved(
session,
self.requester,
request_id=created.id,
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-7",
subject_digest=DIGEST,
)
self.assertTrue(check.approved)
with self.assertRaisesRegex(ApprovalStoreError, "exact requested subject"):
self.service.check_approved(
session,
self.requester,
request_id=created.id,
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-8",
subject_digest=DIGEST,
)
def test_separation_of_duties_rejection_replay_and_tenant_isolation(self) -> None:
direct = ApprovalRequestCreateCommand(
title="Direct review",
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-7",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
"review", "Review", (ApprovalActorSelector("account", "requester"),)
),
),
)
with self.Session() as session:
created = self.service.create_request(
session, self.requester, command=direct, idempotency_key="create-direct"
)
with self.assertRaisesRegex(ApprovalStoreError, "separation of duties"):
self.service.decide(
session,
self.requester,
request_id=created.id,
command=ApprovalDecisionCommand("approved", "Self.", 1, "self-1"),
)
self.assertIsNone(
self.service.get_request(
session, Principal("tenant-2", "requester"), request_id=created.id
)
)
rejected_command = ApprovalRequestCreateCommand(
title="Review",
subject_module="cases",
subject_type="case",
subject_id="case-1",
subject_version="1",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
"review",
"Review",
(ApprovalActorSelector("account", "reviewer"),),
),
),
)
rejected = self.service.create_request(
session,
self.requester,
command=rejected_command,
idempotency_key="create-reject",
)
receipt = self.service.decide(
session,
Principal("tenant-1", "reviewer"),
request_id=rejected.id,
command=ApprovalDecisionCommand(
"rejected", "Insufficient evidence.", 1, "reject-1"
),
)
replay = self.service.decide(
session,
Principal("tenant-1", "reviewer"),
request_id=rejected.id,
command=ApprovalDecisionCommand(
"rejected", "Insufficient evidence.", 1, "reject-1"
),
)
self.assertEqual(receipt.receipt_sha256, replay.receipt_sha256)
self.assertTrue(replay.replayed)
self.assertEqual(
"rejected",
self.service.get_request(
session, self.requester, request_id=rejected.id
)["state"],
)
def test_due_step_escalates_explicitly(self) -> None:
command = ApprovalRequestCreateCommand(
title="Due review",
subject_module="files",
subject_type="file",
subject_id="file-1",
subject_version="1",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
"review",
"Review",
(ApprovalActorSelector("account", "reviewer"),),
due_at=datetime.now(UTC) - timedelta(minutes=1),
),
),
)
with self.Session() as session:
created = self.service.create_request(
session, self.requester, command=command, idempotency_key="create-due"
)
escalated = self.service.escalate_due(
session,
self.requester,
request_id=created.id,
expected_revision=1,
idempotency_key="escalate-1",
)
self.assertEqual("escalated", escalated.state)
def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
template_command = ApprovalTemplateCreateCommand(
key="campaign-release",
title="Campaign release",
steps=(
ApprovalStepDefinition(
"release",
"Release",
(ApprovalActorSelector("role", "senders"),),
forbidden_evidence_roles=("builder",),
),
),
)
with self.Session() as session:
draft = self.service.create_template(
session,
self.requester,
command=template_command,
idempotency_key="template-1",
)
published = self.service.publish_template(
session,
self.requester,
template_id=draft.id,
expected_revision=1,
idempotency_key="publish-1",
)
request = ApprovalRequestCreateCommand(
title="Approve exact execution",
subject_module="campaigns",
subject_type="campaign_execution",
subject_id="campaign-1",
subject_version="build-7",
subject_digest=DIGEST,
steps=(),
evidence_actors={"builder": ("builder",)},
template_id=published.id,
template_revision=published.revision,
)
created = self.service.create_request(
session,
self.requester,
command=request,
idempotency_key="templated-request-1",
)
with self.assertRaisesRegex(ApprovalStoreError, "builder actor"):
self.service.decide(
session,
Principal("tenant-1", "builder", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved", "Built and release attempted.", 1, "builder-release"
),
)
receipt = self.service.decide(
session,
Principal("tenant-1", "sender", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved", "Independent release.", 1, "sender-release"
),
)
self.assertEqual(
"role", receipt.authority_provenance["matched_selector"]["kind"]
)
with self.assertRaisesRegex(ApprovalStoreError, "content digest"):
self.service.check_approved(
session,
self.requester,
request_id=created.id,
subject_module="campaigns",
subject_type="campaign_execution",
subject_id="campaign-1",
subject_version="build-7",
subject_digest="b" * 64,
)
if __name__ == "__main__":
unittest.main()
+20 -8
View File
@@ -2,22 +2,34 @@ from __future__ import annotations
import unittest
from govoplan_approvals.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
from govoplan_approvals.backend.manifest import (
ADMIN_SCOPE,
DECIDE_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
get_manifest,
)
class ManifestSeedTests(unittest.TestCase):
def test_manifest_registers_seed_contract(self) -> None:
class ManifestTests(unittest.TestCase):
def test_manifest_registers_runtime_contract(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "approvals")
self.assertEqual(manifest.name, "Approvals")
self.assertEqual(manifest.dependencies, ("access",))
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
self.assertEqual({role.slug for role in manifest.role_templates}, {"approvals_manager", "approvals_viewer"})
self.assertEqual(
{permission.scope for permission in manifest.permissions},
{READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE, ADMIN_SCOPE},
)
self.assertEqual(
{role.slug for role in manifest.role_templates},
{"approvals_manager", "approver", "approvals_admin"},
)
self.assertTrue(manifest.documentation)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
if __name__ == "__main__":
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_approvals.backend.manifest import get_manifest
from govoplan_core.db.migrations import migrate_database
class ApprovalsMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_approval_runtime_tables(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-approvals-") as directory:
url = f"sqlite:///{Path(directory) / 'approvals.db'}"
migrate_database(
database_url=url,
enabled_modules=("approvals",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
tables = set(inspect(engine).get_table_names())
self.assertTrue(
{
"approval_template_revisions",
"approval_request_revisions",
"approval_decision_records",
"approval_lifecycle_events",
"approval_replays",
}.issubset(tables)
)
with engine.connect() as connection:
self.assertIn(
"8b9c0d1e2f3a",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@govoplan/approvals-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
"./styles/approvals.css": "./src/styles/approvals.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": { "@govoplan/core-webui": { "optional": true } }
}
+54
View File
@@ -0,0 +1,54 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type ApprovalSelector = { kind: "account" | "group" | "role" | "function_assignment" | "any_account"; value: string; label?: string | null };
export type ApprovalStep = { key: string; label: string; selectors: ApprovalSelector[]; required_approvals: number; rejection_policy: "fail_fast" | "collect"; due_at?: string | null; signature_required: boolean; forbidden_evidence_roles: string[]; metadata: Record<string, unknown> };
export type ApprovalRequest = {
id: string;
revision: number;
state: "pending" | "escalated" | "approved" | "rejected" | "cancelled" | "expired";
current_step_key?: string | null;
title: string;
description?: string | null;
subject_module: string;
subject_type: string;
subject_id: string;
subject_version?: string | null;
subject_digest: string;
steps: ApprovalStep[];
current_step_index: number;
separation_of_duties: boolean;
unique_actors_across_steps: boolean;
expires_at?: string | null;
policy_refs: string[];
evidence_actors: Record<string, string[]>;
template_id?: string | null;
template_revision?: number | null;
requested_by?: string | null;
completed_at?: string | null;
metadata: Record<string, unknown>;
};
export type ApprovalDraft = Omit<ApprovalRequest, "id" | "revision" | "state" | "current_step_key" | "current_step_index" | "requested_by" | "completed_at">;
export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
export function listApprovals(settings: ApiSettings, signal?: AbortSignal): Promise<{ requests: ApprovalRequest[] }> {
return apiFetch(settings, apiPath("/api/v1/approvals", { limit: 200 }), { signal });
}
export function getApproval(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalRequest> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}`, { signal });
}
export function approvalHistory(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalEvent[]> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}/history`, { signal });
}
export function createApproval(settings: ApiSettings, request: ApprovalDraft): Promise<ApprovalRequest> {
return apiFetch(settings, "/api/v1/approvals", { method: "POST", body: JSON.stringify({ request, idempotency_key: crypto.randomUUID() }) });
}
export function decideApproval(settings: ApiSettings, request: ApprovalRequest, outcome: "approved" | "rejected", reason: string, signatureRef?: Record<string, unknown>): Promise<{ request: ApprovalRequest }> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/decisions`, {
method: "POST",
body: JSON.stringify({ outcome, reason, expected_revision: request.revision, idempotency_key: crypto.randomUUID(), signature_ref: signatureRef ?? null })
});
}
@@ -0,0 +1,64 @@
import { Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, IconButton, ToggleSwitch, type ApiSettings } from "@govoplan/core-webui";
import { createApproval, type ApprovalDraft, type ApprovalRequest, type ApprovalStep } from "../../api/approvals";
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
const [draft, setDraft] = useState<ApprovalDraft>(() => initialDraft());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const valid = useMemo(() => Boolean(draft.title.trim() && draft.subject_module.trim() && draft.subject_type.trim() && draft.subject_id.trim() && /^[0-9a-f]{64}$/.test(draft.subject_digest) && draft.steps.every((step) => step.key.trim() && step.label.trim() && step.selectors.every((selector) => selector.value.trim()))), [draft]);
async function save() {
setBusy(true);
setError("");
try {
onSaved(await createApproval(settings, draft));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Approval request could not be created.");
} finally {
setBusy(false);
}
}
function patchStep(index: number, patch: Partial<ApprovalStep>) {
setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
return <Dialog open title="New Approval request" onClose={onClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant="primary" disabled={busy || !valid} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
<div className="approval-editor">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="approval-editor-grid">
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Subject module"><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
<FormField label="Subject type"><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
<FormField label="Subject ID"><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
<FormField label="Subject version"><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
<FormField label="Subject SHA-256"><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</div>
<div className="approval-editor-heading"><h3>Steps</h3><Button disabled={busy} onClick={() => setDraft({ ...draft, steps: [...draft.steps, emptyStep(draft.steps.length + 1)] })}><Plus size={16} aria-hidden="true" />Add step</Button></div>
<div className="approval-step-list">
{draft.steps.map((step, index) => <div className="approval-step-editor" key={`${index}:${step.key}`}>
<FormField label="Key"><input value={step.key} disabled={busy} onChange={(event) => patchStep(index, { key: event.target.value })} /></FormField>
<FormField label="Label"><input value={step.label} disabled={busy} onChange={(event) => patchStep(index, { label: event.target.value })} /></FormField>
<FormField label="Actor type"><select value={step.selectors[0].kind} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], kind: event.target.value as ApprovalStep["selectors"][number]["kind"] }] })}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function_assignment">Function assignment</option><option value="any_account">Any account</option></select></FormField>
<FormField label="Actor value"><input value={step.selectors[0].value} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], value: event.target.value }] })} /></FormField>
<FormField label="Required"><input type="number" min={1} value={step.required_approvals} disabled={busy} onChange={(event) => patchStep(index, { required_approvals: Number(event.target.value) })} /></FormField>
<ToggleSwitch label="Signature" checked={step.signature_required} disabled={busy} onChange={(value) => patchStep(index, { signature_required: value })} />
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.steps.length === 1} onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
</div>
</Dialog>;
}
function emptyStep(index: number): ApprovalStep {
return { key: `step-${index}`, label: "", selectors: [{ kind: "account", value: "" }], required_approvals: 1, rejection_policy: "fail_fast", signature_required: false, forbidden_evidence_roles: [], metadata: {} };
}
function initialDraft(): ApprovalDraft {
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
}
@@ -0,0 +1,81 @@
import { Check, Plus, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button, Dialog, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, type PlatformRouteContext } from "@govoplan/core-webui";
import { approvalHistory, decideApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
import ApprovalRequestDialog from "./ApprovalRequestDialog";
export default function ApprovalsPage({ settings, auth }: PlatformRouteContext) {
const [items, setItems] = useState<ApprovalRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<ApprovalRequest | null>(null);
const [history, setHistory] = useState<ApprovalEvent[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [decision, setDecision] = useState<"approved" | "rejected" | null>(null);
const canWrite = hasScope(auth, "approvals:workspace:write");
const canDecide = hasScope(auth, "approvals:workspace:decide");
const load = useCallback(async (signal?: AbortSignal, preferred?: string) => {
setLoading(true);
try {
const response = await listApprovals(settings, signal);
setItems(response.requests);
setSelectedId((current) => preferred ?? current ?? response.requests[0]?.id ?? null);
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval requests could not be loaded.")); });
return () => controller.abort();
}, [load]);
useEffect(() => {
if (!selectedId) { setSelected(null); setHistory([]); return; }
const controller = new AbortController();
Promise.all([getApproval(settings, selectedId, controller.signal), approvalHistory(settings, selectedId, controller.signal)]).then(([item, events]) => { setSelected(item); setHistory(events); }).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval details could not be loaded.")); });
return () => controller.abort();
}, [selectedId, settings]);
async function reload(id: string) {
const [item, events] = await Promise.all([getApproval(settings, id), approvalHistory(settings, id)]);
setSelected(item);
setHistory(events);
await load(undefined, id);
}
return <main className="approvals-page"><div className="approvals-shell">
<aside className="approvals-list-panel">
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void load()} />{canWrite && <Button variant="primary" onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button>}</div>
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div></PageScrollViewport>
</aside>
<section className="approvals-workspace">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canDecide && ["pending", "escalated"].includes(selected.state) && <><Button variant="primary" disabled={busy} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></>}</div></header>
<div className="approval-metrics"><Metric label="Revision" value={selected.revision} /><Metric label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><Metric label="Requested by" value={selected.requested_by || "-"} /><Metric label="Steps" value={selected.steps.length} /></div>
{selected.description && <p>{selected.description}</p>}
<section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section>
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString()}</time></div>)}</div></section>
</div></PageScrollViewport>}
</section>
</div>
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); } finally { setBusy(false); } }} />}
</main>;
}
function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise<void> }) {
const [reason, setReason] = useState("");
const [signatureId, setSignatureId] = useState("");
return <Dialog open title={`${humanize(outcome)} request`} onClose={onClose} closeDisabled={busy} portal footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !reason.trim() || (signatureRequired && !signatureId.trim())} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference"><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
}
function Metric({ label, value }: { label: string; value: string | number }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; }
function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; }
+2
View File
@@ -0,0 +1,2 @@
export { default, approvalsModule } from "./module";
export * from "./api/approvals";
+21
View File
@@ -0,0 +1,21 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/approvals.css";
const ApprovalsPage = lazy(() => import("./features/approvals/ApprovalsPage"));
export const approvalsModule: PlatformWebModule = {
id: "approvals",
label: "Approvals",
version: "0.1.14",
dependencies: ["access"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
navItems: [{ to: "/approvals", label: "Approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
viewSurfaces: [
{ id: "approvals.navigation", moduleId: "approvals", kind: "navigation", label: "Approvals navigation", order: 10 },
{ id: "approvals.workspace", moduleId: "approvals", kind: "route", label: "Approval request workspace", order: 20 }
]
};
export default approvalsModule;
+34
View File
@@ -0,0 +1,34 @@
.approvals-page { height: 100%; min-height: 0; overflow: hidden; }
.approvals-shell { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); height: 100%; min-height: 0; }
.approvals-list-panel { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--border-color, #d8dde3); }
.approvals-toolbar { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #d8dde3); }
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
.approvals-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; }
.approvals-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 52px; padding: 7px 8px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.approvals-list button:hover, .approvals-list button.is-selected { background: var(--hover-bg, rgba(54, 99, 135, 0.1)); }
.approvals-list button > span { display: flex; min-width: 0; flex-direction: column; }
.approvals-list small, .approvals-detail header span { color: var(--text-muted, #65717e); }
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.approvals-detail { padding: 16px 20px 28px; }
.approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color, #d8dde3); }
.approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; }
.approvals-detail h2, .approvals-detail h3, .approval-editor-heading h3 { margin: 0; font-size: 1rem; letter-spacing: 0; }
.approval-metrics { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 10px; margin: 16px 0; }
.approval-metrics > div { display: flex; flex-direction: column; padding: 10px 12px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
.approval-metrics span { color: var(--text-muted, #65717e); font-size: .75rem; text-transform: uppercase; }
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-color, #d8dde3); }
.approval-chain, .approval-history { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.approval-chain > div, .approval-history > div { display: grid; grid-template-columns: 32px minmax(120px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 7px 9px; background: var(--surface-muted, rgba(127,137,147,.08)); }
.approval-chain > div.is-current { border-left: 3px solid var(--accent-color, #36709a); }
.approval-chain em { font-size: .8rem; font-style: normal; }
.approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; }
.approval-history time { color: var(--text-muted, #65717e); font-size: .82rem; }
.approval-request-dialog { width: min(1080px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
.approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
.approval-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.approval-editor-wide { grid-column: 1 / -1; }
.approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-list { display: flex; flex-direction: column; gap: 8px; }
.approval-step-editor { display: grid; grid-template-columns: minmax(100px,.7fr) minmax(130px,1fr) 150px minmax(140px,1fr) 80px 120px 34px; align-items: end; gap: 8px; }
.approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; }
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-editor { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }