Implement generic approval runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
+254
@@ -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."""
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, approvalsModule } from "./module";
|
||||
export * from "./api/approvals";
|
||||
@@ -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;
|
||||
@@ -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; } }
|
||||
Reference in New Issue
Block a user