diff --git a/README.md b/README.md index 52dffb2..5982fd0 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@ **Repository type:** module (domain). -`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 diff --git a/docs/APPROVALS_DOMAIN_BOUNDARY.md b/docs/APPROVALS_DOMAIN_BOUNDARY.md index 7941de9..c2ce0df 100644 --- a/docs/APPROVALS_DOMAIN_BOUNDARY.md +++ b/docs/APPROVALS_DOMAIN_BOUNDARY.md @@ -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. diff --git a/package.json b/package.json index 9051351..85fd675 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/pyproject.toml b/pyproject.toml index 8eb3b05..73d6283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/govoplan_approvals/backend/db/__init__.py b/src/govoplan_approvals/backend/db/__init__.py new file mode 100644 index 0000000..2258fea --- /dev/null +++ b/src/govoplan_approvals/backend/db/__init__.py @@ -0,0 +1,13 @@ +from govoplan_approvals.backend.db.models import ( + ApprovalDecisionRecord, + ApprovalLifecycleEvent, + ApprovalRequestRevision, + ApprovalReplay, +) + +__all__ = [ + "ApprovalDecisionRecord", + "ApprovalLifecycleEvent", + "ApprovalReplay", + "ApprovalRequestRevision", +] diff --git a/src/govoplan_approvals/backend/db/models.py b/src/govoplan_approvals/backend/db/models.py new file mode 100644 index 0000000..7f351d1 --- /dev/null +++ b/src/govoplan_approvals/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_approvals/backend/manifest.py b/src/govoplan_approvals/backend/manifest.py index 90d2f49..9b702c4 100644 --- a/src/govoplan_approvals/backend/manifest.py +++ b/src/govoplan_approvals/backend/manifest.py @@ -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",), ), ) diff --git a/src/govoplan_approvals/backend/migrations/__init__.py b/src/govoplan_approvals/backend/migrations/__init__.py new file mode 100644 index 0000000..ab06adb --- /dev/null +++ b/src/govoplan_approvals/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Approvals migrations.""" diff --git a/src/govoplan_approvals/backend/migrations/versions/8b9c0d1e2f3a_v0114_approvals_runtime.py b/src/govoplan_approvals/backend/migrations/versions/8b9c0d1e2f3a_v0114_approvals_runtime.py new file mode 100644 index 0000000..3598a32 --- /dev/null +++ b/src/govoplan_approvals/backend/migrations/versions/8b9c0d1e2f3a_v0114_approvals_runtime.py @@ -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") diff --git a/src/govoplan_approvals/backend/migrations/versions/__init__.py b/src/govoplan_approvals/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..f7bf35d --- /dev/null +++ b/src/govoplan_approvals/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Approvals migration revisions.""" diff --git a/src/govoplan_approvals/backend/router.py b/src/govoplan_approvals/backend/router.py new file mode 100644 index 0000000..20b561c --- /dev/null +++ b/src/govoplan_approvals/backend/router.py @@ -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"] diff --git a/src/govoplan_approvals/backend/schemas.py b/src/govoplan_approvals/backend/schemas.py new file mode 100644 index 0000000..038b571 --- /dev/null +++ b/src/govoplan_approvals/backend/schemas.py @@ -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", +] diff --git a/src/govoplan_approvals/backend/service.py b/src/govoplan_approvals/backend/service.py new file mode 100644 index 0000000..5c9aa98 --- /dev/null +++ b/src/govoplan_approvals/backend/service.py @@ -0,0 +1,1247 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import asdict +from datetime import UTC, datetime +import hashlib +import json +from typing import Any +import uuid + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.core.approvals import ( + ApprovalCheck, + ApprovalDecisionCommand, + ApprovalDecisionReceipt, + ApprovalRequestCreateCommand, + ApprovalRequestRef, + ApprovalTemplateCreateCommand, + ApprovalTemplateRef, +) +from govoplan_approvals.backend.db.models import ( + ApprovalDecisionRecord, + ApprovalLifecycleEvent, + ApprovalReplay, + ApprovalRequestRevision, + ApprovalTemplateRevision, +) + + +class ApprovalStoreError(ValueError): + pass + + +class SqlApprovalRequests: + def create_template( + self, + session: object, + principal: object, + *, + command: ApprovalTemplateCreateCommand, + idempotency_key: str, + ) -> ApprovalTemplateRef: + typed_session = _session(session) + tenant_id = _tenant(principal) + payload = _template_payload(command) + _validate_template(payload) + request = {"command": payload} + replay = _read_replay( + typed_session, tenant_id, "create_template", idempotency_key, request + ) + if replay is not None: + return _template_ref_from(replay) + if ( + typed_session.query(ApprovalTemplateRevision) + .filter( + ApprovalTemplateRevision.tenant_id == tenant_id, + ApprovalTemplateRevision.key == payload["key"], + ApprovalTemplateRevision.superseded_at.is_(None), + ) + .first() + is not None + ): + raise ApprovalStoreError("Approval template key already exists.") + row = ApprovalTemplateRevision( + tenant_id=tenant_id, + template_id=str(uuid.uuid4()), + key=str(payload["key"]), + revision=1, + state="draft", + content_sha256=_sha256(payload), + recorded_at=_now(), + payload=payload, + actor_id=_actor(principal), + ) + typed_session.add(row) + typed_session.flush() + result = _template_ref(row) + _write_replay( + typed_session, + tenant_id, + "create_template", + idempotency_key, + request, + _template_ref_mapping(result), + ) + return result + + def revise_template( + self, + session: object, + principal: object, + *, + template_id: str, + command: ApprovalTemplateCreateCommand, + expected_revision: int, + idempotency_key: str, + ) -> ApprovalTemplateRef: + typed_session = _session(session) + tenant_id = _tenant(principal) + current = _required_template_current( + typed_session, tenant_id=tenant_id, template_id=template_id, lock=True + ) + payload = _template_payload(command) + _validate_template(payload) + request = { + "template_id": template_id, + "expected_revision": expected_revision, + "command": payload, + } + operation = f"revise_template:{template_id}" + replay = _read_replay( + typed_session, tenant_id, operation, idempotency_key, request + ) + if replay is not None: + return _template_ref_from(replay) + if current.revision != expected_revision: + raise ApprovalStoreError( + "Approval template revision conflict: the expected revision is stale." + ) + if current.key != payload["key"]: + raise ApprovalStoreError( + "Approval template keys are stable and cannot be changed." + ) + revised = _revise_template( + typed_session, + current=current, + principal=principal, + state="draft", + payload=payload, + ) + result = _template_ref(revised) + _write_replay( + typed_session, + tenant_id, + operation, + idempotency_key, + request, + _template_ref_mapping(result), + ) + return result + + def publish_template( + self, + session: object, + principal: object, + *, + template_id: str, + expected_revision: int, + idempotency_key: str, + ) -> ApprovalTemplateRef: + typed_session = _session(session) + tenant_id = _tenant(principal) + current = _required_template_current( + typed_session, tenant_id=tenant_id, template_id=template_id, lock=True + ) + request = {"template_id": template_id, "expected_revision": expected_revision} + operation = f"publish_template:{template_id}" + replay = _read_replay( + typed_session, tenant_id, operation, idempotency_key, request + ) + if replay is not None: + return _template_ref_from(replay) + if current.revision != expected_revision: + raise ApprovalStoreError( + "Approval template revision conflict: the expected revision is stale." + ) + if current.state != "draft": + raise ApprovalStoreError("Only a draft Approval template can be published.") + revised = _revise_template( + typed_session, + current=current, + principal=principal, + state="published", + payload=current.payload, + ) + result = _template_ref(revised) + _write_replay( + typed_session, + tenant_id, + operation, + idempotency_key, + request, + _template_ref_mapping(result), + ) + return result + + def get_template( + self, + session: object, + principal: object, + *, + template_id: str, + revision: int | None = None, + ) -> Mapping[str, object] | None: + query = ( + _session(session) + .query(ApprovalTemplateRevision) + .filter( + ApprovalTemplateRevision.tenant_id == _tenant(principal), + ApprovalTemplateRevision.template_id == template_id, + ) + ) + if revision is None: + query = query.filter(ApprovalTemplateRevision.superseded_at.is_(None)) + else: + query = query.filter(ApprovalTemplateRevision.revision == revision) + row = query.one_or_none() + return _template_mapping(row) if row is not None else None + + def list_templates( + self, + session: object, + principal: object, + *, + state: str | None = None, + limit: int = 100, + ) -> tuple[Mapping[str, object], ...]: + if not 1 <= limit <= 200: + raise ApprovalStoreError( + "Approval template list limit must be between 1 and 200." + ) + query = ( + _session(session) + .query(ApprovalTemplateRevision) + .filter( + ApprovalTemplateRevision.tenant_id == _tenant(principal), + ApprovalTemplateRevision.superseded_at.is_(None), + ) + ) + if state: + query = query.filter(ApprovalTemplateRevision.state == state) + return tuple( + _template_mapping(row) + for row in query.order_by(ApprovalTemplateRevision.key.asc()) + .limit(limit) + .all() + ) + + def create_request( + self, + session: object, + principal: object, + *, + command: ApprovalRequestCreateCommand, + idempotency_key: str, + ) -> ApprovalRequestRef: + typed_session = _session(session) + tenant_id = _tenant(principal) + template = None + if command.template_id is not None: + if command.template_revision is None: + raise ApprovalStoreError( + "Approval template revision is required with a template id." + ) + if command.steps: + raise ApprovalStoreError( + "Approval requests cannot combine template and inline steps." + ) + template = ( + typed_session.query(ApprovalTemplateRevision) + .filter( + ApprovalTemplateRevision.tenant_id == tenant_id, + ApprovalTemplateRevision.template_id == command.template_id, + ApprovalTemplateRevision.revision == command.template_revision, + ) + .one_or_none() + ) + if template is None or template.state != "published": + raise ApprovalStoreError( + "The exact published Approval template revision is unavailable." + ) + payload = _payload(command, requested_by=_actor(principal), template=template) + _validate(payload) + request = {"command": payload} + replay = _read_replay( + typed_session, tenant_id, "create", idempotency_key, request + ) + if replay is not None: + return _ref_from(replay) + request_id = str(uuid.uuid4()) + now = _now() + first_step = str(payload["steps"][0]["key"]) + row = ApprovalRequestRevision( + tenant_id=tenant_id, + request_id=request_id, + revision=1, + state="pending", + current_step_key=first_step, + subject_module=str(payload["subject_module"]), + subject_type=str(payload["subject_type"]), + subject_id=str(payload["subject_id"]), + subject_version=_text(payload.get("subject_version")), + subject_digest=str(payload["subject_digest"]), + recorded_at=now, + payload=payload, + actor_id=_actor(principal), + ) + typed_session.add(row) + typed_session.flush() + _event( + typed_session, + row, + principal, + "approval.requested", + {"revision": 1, "step_key": first_step}, + ) + result = _ref(row) + _write_replay( + typed_session, + tenant_id, + "create", + idempotency_key, + request, + _ref_mapping(result), + ) + return result + + def get_request( + self, session: object, principal: object, *, request_id: str + ) -> Mapping[str, object] | None: + row = _find_current( + _session(session), + tenant_id=_tenant(principal), + request_id=request_id, + lock=False, + ) + return _mapping(row) if row is not None else None + + def list_requests( + self, + session: object, + principal: object, + *, + state: str | None = None, + subject_module: str | None = None, + subject_id: str | None = None, + limit: int = 100, + ) -> tuple[Mapping[str, object], ...]: + typed_session = _session(session) + if not 1 <= limit <= 200: + raise ApprovalStoreError("Approval list limit must be between 1 and 200.") + query = typed_session.query(ApprovalRequestRevision).filter( + ApprovalRequestRevision.tenant_id == _tenant(principal), + ApprovalRequestRevision.superseded_at.is_(None), + ) + if state: + query = query.filter(ApprovalRequestRevision.state == state) + if subject_module: + query = query.filter( + ApprovalRequestRevision.subject_module == subject_module + ) + if subject_id: + query = query.filter(ApprovalRequestRevision.subject_id == subject_id) + return tuple( + _mapping(row) + for row in query.order_by(ApprovalRequestRevision.recorded_at.desc()) + .limit(limit) + .all() + ) + + def decide( + self, + session: object, + principal: object, + *, + request_id: str, + command: ApprovalDecisionCommand, + ) -> ApprovalDecisionReceipt: + typed_session = _session(session) + tenant_id = _tenant(principal) + actor_id = _actor(principal) + if actor_id is None: + raise ApprovalStoreError( + "Approval decisions require an authenticated account." + ) + outcome = str(command.outcome or "").strip().lower() + if outcome not in {"approved", "rejected"}: + raise ApprovalStoreError("Approval outcome must be approved or rejected.") + reason = str(command.reason or "").strip() + if not reason: + raise ApprovalStoreError("Approval decisions require a reason.") + idempotency_key = _idempotency(command.idempotency_key) + existing = ( + typed_session.query(ApprovalDecisionRecord) + .filter( + ApprovalDecisionRecord.tenant_id == tenant_id, + ApprovalDecisionRecord.request_id == request_id, + ApprovalDecisionRecord.idempotency_key == idempotency_key, + ) + .one_or_none() + ) + if existing is not None: + if existing.outcome != outcome or existing.reason != reason: + raise ApprovalStoreError( + "Approval idempotency key was reused for another decision." + ) + replay_state = _required_current( + typed_session, tenant_id=tenant_id, request_id=request_id, lock=False + ).state + return _decision_receipt(existing, state=replay_state, replayed=True) + current = _required_current( + typed_session, tenant_id=tenant_id, request_id=request_id, lock=True + ) + if current.revision != command.expected_revision: + raise ApprovalStoreError( + "Approval revision conflict: the expected revision is stale." + ) + if current.state not in {"pending", "escalated"}: + raise ApprovalStoreError( + "This Approval request no longer accepts decisions." + ) + expires_at = _datetime(current.payload.get("expires_at")) + if expires_at is not None and _now() >= expires_at: + raise ApprovalStoreError("This Approval request has expired.") + steps = list(current.payload["steps"]) + step_index = int(current.payload.get("current_step_index") or 0) + step = steps[step_index] + effective_actor = _effective_actor(principal, command.delegated_for_account_id) + matched_selector = _matched_selector( + principal, step.get("selectors") or [], effective_actor + ) + if matched_selector is None: + raise ApprovalStoreError( + "The current principal is not eligible for this Approval step." + ) + if bool( + current.payload.get("separation_of_duties") + ) and effective_actor == current.payload.get("requested_by"): + raise ApprovalStoreError( + "Approval separation of duties prevents requester self-approval." + ) + prior = ( + typed_session.query(ApprovalDecisionRecord) + .filter( + ApprovalDecisionRecord.tenant_id == tenant_id, + ApprovalDecisionRecord.request_id == request_id, + ApprovalDecisionRecord.effective_actor_id == effective_actor, + ) + .all() + ) + if any(item.step_key == step["key"] for item in prior): + raise ApprovalStoreError( + "This actor has already decided the current Approval step." + ) + if bool(current.payload.get("unique_actors_across_steps")) and any( + item.outcome == "approved" for item in prior + ): + raise ApprovalStoreError( + "Approval policy requires a different actor for each step." + ) + evidence_actors = { + str(key): {str(actor) for actor in (actors or [])} + for key, actors in dict( + current.payload.get("evidence_actors") or {} + ).items() + } + for role in step.get("forbidden_evidence_roles") or []: + if effective_actor in evidence_actors.get(str(role), set()): + raise ApprovalStoreError( + f"Approval separation of duties prevents the {role} actor from deciding this step." + ) + signature_ref = ( + dict(command.signature_ref) if command.signature_ref is not None else None + ) + if bool(step.get("signature_required")) and not signature_ref: + raise ApprovalStoreError( + "This Approval step requires a signature reference." + ) + recorded_at = _now() + delegation_id = _delegation_id(principal, command.delegated_for_account_id) + authority_provenance = { + "matched_selector": matched_selector, + "principal_actor_id": actor_id, + "effective_actor_id": effective_actor, + "delegation_id": delegation_id, + } + receipt_hash = _sha256( + { + "tenant_id": tenant_id, + "request_id": request_id, + "revision": current.revision + 1, + "step_key": step["key"], + "outcome": outcome, + "reason": reason, + "actor_id": actor_id, + "effective_actor_id": effective_actor, + "authority_provenance": authority_provenance, + "signature_ref": signature_ref, + "recorded_at": recorded_at.isoformat(), + } + ) + decision = ApprovalDecisionRecord( + tenant_id=tenant_id, + request_id=request_id, + request_revision=current.revision + 1, + step_key=str(step["key"]), + outcome=outcome, + reason=reason, + actor_id=actor_id, + effective_actor_id=effective_actor, + delegation_id=delegation_id, + authority_provenance=authority_provenance, + signature_ref=signature_ref, + recorded_at=recorded_at, + idempotency_key=idempotency_key, + receipt_sha256=receipt_hash, + ) + typed_session.add(decision) + typed_session.flush() + payload = dict(current.payload) + state = current.state + next_step_key: str | None = str(step["key"]) + if ( + outcome == "rejected" + and str(step.get("rejection_policy") or "fail_fast") == "fail_fast" + ): + state = "rejected" + next_step_key = None + payload["completed_at"] = recorded_at.isoformat() + elif outcome == "approved": + count = ( + typed_session.query(ApprovalDecisionRecord) + .filter( + ApprovalDecisionRecord.tenant_id == tenant_id, + ApprovalDecisionRecord.request_id == request_id, + ApprovalDecisionRecord.step_key == step["key"], + ApprovalDecisionRecord.outcome == "approved", + ) + .count() + ) + if count >= int(step.get("required_approvals") or 1): + step_index += 1 + payload["current_step_index"] = step_index + if step_index >= len(steps): + state = "approved" + next_step_key = None + payload["completed_at"] = recorded_at.isoformat() + else: + state = "pending" + next_step_key = str(steps[step_index]["key"]) + revised = _revise( + typed_session, + current=current, + principal=principal, + state=state, + current_step_key=next_step_key, + payload=payload, + event_type="approval.decision_recorded", + event_payload={ + "step_key": step["key"], + "outcome": outcome, + "receipt_sha256": receipt_hash, + "effective_actor_id": effective_actor, + "authority_provenance": authority_provenance, + "next_step_key": next_step_key, + }, + ) + decision.request_revision = revised.revision + typed_session.flush() + return _decision_receipt(decision, state=revised.state) + + def check_approved( + self, + session: object, + principal: object, + *, + request_id: str, + subject_module: str, + subject_type: str, + subject_id: str, + subject_version: str | None, + subject_digest: str, + ) -> ApprovalCheck: + row = _required_current( + _session(session), + tenant_id=_tenant(principal), + request_id=request_id, + lock=False, + ) + expected = (subject_module, subject_type, subject_id, subject_version) + actual = ( + row.subject_module, + row.subject_type, + row.subject_id, + row.subject_version, + ) + if expected != actual: + raise ApprovalStoreError( + "Approval request does not cover the exact requested subject revision." + ) + if row.subject_digest != str(subject_digest or "").strip().lower(): + raise ApprovalStoreError( + "Approval request does not cover the exact requested subject content digest." + ) + return ApprovalCheck( + request_id=request_id, + revision=row.revision, + state=row.state, + approved=row.state == "approved", + subject_module=row.subject_module, + subject_type=row.subject_type, + subject_id=row.subject_id, + subject_version=row.subject_version, + subject_digest=row.subject_digest, + completed_at=_datetime(row.payload.get("completed_at")), + ) + + def escalate_due( + self, + session: object, + principal: object, + *, + request_id: str, + expected_revision: int, + idempotency_key: str, + ) -> ApprovalRequestRef: + typed_session = _session(session) + current = _required_current( + typed_session, + tenant_id=_tenant(principal), + request_id=request_id, + lock=True, + ) + request = {"request_id": request_id, "expected_revision": expected_revision} + operation = f"escalate:{request_id}" + replay = _read_replay( + typed_session, current.tenant_id, operation, idempotency_key, request + ) + if replay is not None: + return _ref_from(replay) + if current.revision != expected_revision: + raise ApprovalStoreError( + "Approval revision conflict: the expected revision is stale." + ) + if current.state != "pending": + raise ApprovalStoreError( + "Only a pending Approval request can be escalated." + ) + step = current.payload["steps"][ + int(current.payload.get("current_step_index") or 0) + ] + due_at = _datetime(step.get("due_at")) + if due_at is None or _now() < due_at: + raise ApprovalStoreError( + "The current Approval step is not due for escalation." + ) + revised = _revise( + typed_session, + current=current, + principal=principal, + state="escalated", + current_step_key=current.current_step_key, + payload=current.payload, + event_type="approval.escalated", + event_payload={ + "step_key": current.current_step_key, + "due_at": _iso(due_at), + }, + ) + response = _ref_mapping(_ref(revised)) + _write_replay( + typed_session, + current.tenant_id, + operation, + idempotency_key, + request, + response, + ) + return _ref_from(response) + + def history( + self, session: object, principal: object, *, request_id: str + ) -> tuple[Mapping[str, object], ...]: + typed_session = _session(session) + rows = ( + typed_session.query(ApprovalLifecycleEvent) + .filter( + ApprovalLifecycleEvent.tenant_id == _tenant(principal), + ApprovalLifecycleEvent.request_id == request_id, + ) + .order_by(ApprovalLifecycleEvent.sequence.asc()) + .all() + ) + return tuple( + { + "sequence": row.sequence, + "event_type": row.event_type, + "recorded_at": _iso(row.recorded_at), + "actor_id": row.actor_id, + "payload": dict(row.payload), + } + for row in rows + ) + + +def _step_payload(step: object) -> dict[str, Any]: + return { + "key": str(getattr(step, "key")), + "label": str(getattr(step, "label")), + "selectors": [asdict(selector) for selector in getattr(step, "selectors")], + "required_approvals": int(getattr(step, "required_approvals")), + "rejection_policy": str(getattr(step, "rejection_policy")), + "due_at": _iso(getattr(step, "due_at")), + "signature_required": bool(getattr(step, "signature_required")), + "forbidden_evidence_roles": list(getattr(step, "forbidden_evidence_roles", ())), + "metadata": dict(getattr(step, "metadata")), + } + + +def _template_payload(command: ApprovalTemplateCreateCommand) -> dict[str, Any]: + return { + "key": str(command.key or "").strip(), + "title": str(command.title or "").strip(), + "description": _text(command.description), + "steps": [_step_payload(step) for step in command.steps], + "separation_of_duties": command.separation_of_duties, + "unique_actors_across_steps": command.unique_actors_across_steps, + "metadata": dict(command.metadata), + } + + +def _validate_template(payload: Mapping[str, Any]) -> None: + if not str(payload.get("key") or "").strip(): + raise ApprovalStoreError("Approval template key is required.") + if not str(payload.get("title") or "").strip(): + raise ApprovalStoreError("Approval template title is required.") + _validate_steps(list(payload.get("steps") or [])) + + +def _payload( + command: ApprovalRequestCreateCommand, + *, + requested_by: str | None, + template: ApprovalTemplateRevision | None, +) -> dict[str, Any]: + template_payload = dict(template.payload) if template is not None else None + steps = ( + list(template_payload["steps"]) + if template_payload is not None + else [_step_payload(step) for step in command.steps] + ) + return { + "title": str(command.title or "").strip(), + "description": _text(command.description), + "subject_module": str(command.subject_module or "").strip(), + "subject_type": str(command.subject_type or "").strip(), + "subject_id": str(command.subject_id or "").strip(), + "subject_version": _text(command.subject_version), + "subject_digest": str(command.subject_digest or "").strip().lower(), + "steps": steps, + "current_step_index": 0, + "separation_of_duties": ( + bool(template_payload["separation_of_duties"]) + if template_payload is not None + else command.separation_of_duties + ), + "unique_actors_across_steps": ( + bool(template_payload["unique_actors_across_steps"]) + if template_payload is not None + else command.unique_actors_across_steps + ), + "expires_at": _iso(command.expires_at), + "policy_refs": list(command.policy_refs), + "evidence_actors": { + str(role): sorted({str(actor) for actor in actors if str(actor).strip()}) + for role, actors in command.evidence_actors.items() + }, + "template": ( + { + "id": template.template_id, + "key": template.key, + "revision": template.revision, + "content_sha256": template.content_sha256, + } + if template is not None + else None + ), + "metadata": dict(command.metadata), + "requested_by": requested_by, + "completed_at": None, + } + + +def _validate(payload: Mapping[str, Any]) -> None: + for key in ("title", "subject_module", "subject_type", "subject_id"): + if not str(payload.get(key) or "").strip(): + raise ApprovalStoreError(f"Approval {key.replace('_', ' ')} is required.") + digest = str(payload.get("subject_digest") or "").strip().lower() + if len(digest) != 64 or any( + character not in "0123456789abcdef" for character in digest + ): + raise ApprovalStoreError( + "Approval subject digest must be a lowercase SHA-256 digest." + ) + _validate_steps( + list(payload.get("steps") or []), + evidence_actors=dict(payload.get("evidence_actors") or {}), + ) + + +def _validate_steps( + steps: list[Mapping[str, Any]], + *, + evidence_actors: Mapping[str, object] | None = None, +) -> None: + keys = [str(item.get("key") or "").strip() for item in steps] + if not steps or any(not key for key in keys) or len(keys) != len(set(keys)): + raise ApprovalStoreError("Approval steps require unique non-empty keys.") + for step in steps: + selectors = list(step.get("selectors") or []) + if not selectors: + raise ApprovalStoreError("Each Approval step requires an actor selector.") + allowed = {"account", "group", "role", "function_assignment", "any_account"} + if any( + str(item.get("kind") or "") not in allowed + or not str(item.get("value") or "").strip() + for item in selectors + ): + raise ApprovalStoreError("Approval actor selector is invalid.") + required = int(step.get("required_approvals") or 0) + if required < 1: + raise ApprovalStoreError("Approval required_approvals must be positive.") + if str(step.get("rejection_policy") or "") not in {"fail_fast", "collect"}: + raise ApprovalStoreError("Approval rejection policy is invalid.") + forbidden_roles = [ + str(role).strip() for role in step.get("forbidden_evidence_roles") or [] + ] + if any(not role for role in forbidden_roles) or len(forbidden_roles) != len( + set(forbidden_roles) + ): + raise ApprovalStoreError( + "Approval forbidden evidence roles must be unique non-empty values." + ) + if evidence_actors is not None: + missing = [ + role + for role in forbidden_roles + if not list(evidence_actors.get(role) or []) + ] + if missing: + raise ApprovalStoreError( + "Approval evidence actors are required for constrained roles: " + + ", ".join(sorted(missing)) + ) + + +def _matched_selector( + principal: object, + selectors: list[Mapping[str, Any]], + effective_actor: str, +) -> dict[str, object] | None: + groups = set(getattr(principal, "group_ids", ()) or ()) + roles = set(getattr(principal, "role_ids", ()) or ()) + functions = set(getattr(principal, "function_assignment_ids", ()) or ()) + for selector in selectors: + kind = str(selector.get("kind") or "") + value = str(selector.get("value") or "") + if kind == "account" and value == effective_actor: + return dict(selector) + if kind == "any_account" and value == "*": + return dict(selector) + if kind == "group" and value in groups: + return dict(selector) + if kind == "role" and value in roles: + return dict(selector) + if kind == "function_assignment" and value in functions: + return dict(selector) + return None + + +def _effective_actor(principal: object, delegated_for: str | None) -> str: + actor = _actor(principal) + if actor is None: + raise ApprovalStoreError("Approval decisions require an authenticated account.") + target = _text(delegated_for) + if target is None: + return actor + if _text(getattr(principal, "acting_for_account_id", None)) != target: + raise ApprovalStoreError( + "Approval delegation is not present in the trusted principal." + ) + return target + + +def _delegation_id(principal: object, delegated_for: str | None) -> str | None: + if not _text(delegated_for): + return None + direct = _text(getattr(principal, "acting_assignment_id", None)) + if direct: + return direct + values = getattr(principal, "delegation_ids", ()) or () + if isinstance(values, str): + return _text(values) + normalized = sorted(str(item).strip() for item in values if str(item).strip()) + return normalized[0] if normalized else None + + +def _required_current( + session: Session, *, tenant_id: str, request_id: str, lock: bool +) -> ApprovalRequestRevision: + row = _find_current(session, tenant_id=tenant_id, request_id=request_id, lock=lock) + if row is None: + raise LookupError("Approval request not found.") + return row + + +def _find_current( + session: Session, *, tenant_id: str, request_id: str, lock: bool +) -> ApprovalRequestRevision | None: + query = session.query(ApprovalRequestRevision).filter( + ApprovalRequestRevision.tenant_id == tenant_id, + ApprovalRequestRevision.request_id == request_id, + ApprovalRequestRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _revise( + session: Session, + *, + current: ApprovalRequestRevision, + principal: object, + state: str, + current_step_key: str | None, + payload: Mapping[str, Any], + event_type: str, + event_payload: Mapping[str, Any], +) -> ApprovalRequestRevision: + now = _now() + current.superseded_at = now + row = ApprovalRequestRevision( + tenant_id=current.tenant_id, + request_id=current.request_id, + revision=current.revision + 1, + previous_revision_id=current.id, + state=state, + current_step_key=current_step_key, + subject_module=current.subject_module, + subject_type=current.subject_type, + subject_id=current.subject_id, + subject_version=current.subject_version, + subject_digest=current.subject_digest, + recorded_at=now, + payload=dict(payload), + actor_id=_actor(principal), + ) + session.add(row) + session.flush() + _event(session, row, principal, event_type, event_payload) + return row + + +def _event( + session: Session, + row: ApprovalRequestRevision, + principal: object, + event_type: str, + payload: Mapping[str, Any], +) -> None: + sequence = ( + session.query(func.max(ApprovalLifecycleEvent.sequence)) + .filter( + ApprovalLifecycleEvent.tenant_id == row.tenant_id, + ApprovalLifecycleEvent.request_id == row.request_id, + ) + .scalar() + or 0 + ) + 1 + session.add( + ApprovalLifecycleEvent( + tenant_id=row.tenant_id, + request_id=row.request_id, + sequence=sequence, + event_type=event_type, + recorded_at=_now(), + actor_id=_actor(principal), + payload=dict(payload), + ) + ) + session.flush() + + +def _mapping(row: ApprovalRequestRevision) -> dict[str, object]: + return { + "id": row.request_id, + "revision": row.revision, + "state": row.state, + "current_step_key": row.current_step_key, + "recorded_at": _iso(row.recorded_at), + **dict(row.payload), + } + + +def _ref(row: ApprovalRequestRevision) -> ApprovalRequestRef: + return ApprovalRequestRef( + row.request_id, row.revision, row.state, row.current_step_key + ) + + +def _ref_mapping(value: ApprovalRequestRef) -> dict[str, object]: + return { + "id": value.id, + "revision": value.revision, + "state": value.state, + "current_step_key": value.current_step_key, + } + + +def _ref_from(value: Mapping[str, Any]) -> ApprovalRequestRef: + return ApprovalRequestRef( + str(value["id"]), + int(value["revision"]), + str(value["state"]), + _text(value.get("current_step_key")), + ) + + +def _required_template_current( + session: Session, + *, + tenant_id: str, + template_id: str, + lock: bool, +) -> ApprovalTemplateRevision: + query = session.query(ApprovalTemplateRevision).filter( + ApprovalTemplateRevision.tenant_id == tenant_id, + ApprovalTemplateRevision.template_id == template_id, + ApprovalTemplateRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + row = query.one_or_none() + if row is None: + raise LookupError("Approval template not found.") + return row + + +def _revise_template( + session: Session, + *, + current: ApprovalTemplateRevision, + principal: object, + state: str, + payload: Mapping[str, Any], +) -> ApprovalTemplateRevision: + now = _now() + current.superseded_at = now + row = ApprovalTemplateRevision( + tenant_id=current.tenant_id, + template_id=current.template_id, + key=current.key, + revision=current.revision + 1, + previous_revision_id=current.id, + state=state, + content_sha256=_sha256(payload), + recorded_at=now, + payload=dict(payload), + actor_id=_actor(principal), + ) + session.add(row) + session.flush() + return row + + +def _template_mapping(row: ApprovalTemplateRevision) -> dict[str, object]: + return { + "id": row.template_id, + "revision": row.revision, + "state": row.state, + "content_sha256": row.content_sha256, + "recorded_at": _iso(row.recorded_at), + **dict(row.payload), + } + + +def _template_ref(row: ApprovalTemplateRevision) -> ApprovalTemplateRef: + return ApprovalTemplateRef( + id=row.template_id, + key=row.key, + revision=row.revision, + state=row.state, + content_sha256=row.content_sha256, + ) + + +def _template_ref_mapping(value: ApprovalTemplateRef) -> dict[str, object]: + return { + "id": value.id, + "key": value.key, + "revision": value.revision, + "state": value.state, + "content_sha256": value.content_sha256, + } + + +def _template_ref_from(value: Mapping[str, Any]) -> ApprovalTemplateRef: + return ApprovalTemplateRef( + id=str(value["id"]), + key=str(value["key"]), + revision=int(value["revision"]), + state=str(value["state"]), + content_sha256=str(value["content_sha256"]), + ) + + +def _decision_receipt( + row: ApprovalDecisionRecord, + *, + state: str, + replayed: bool = False, +) -> ApprovalDecisionReceipt: + return ApprovalDecisionReceipt( + request_id=row.request_id, + revision=row.request_revision, + state=state, + step_key=row.step_key, + outcome=row.outcome, + actor_id=row.actor_id, + recorded_at=_aware(row.recorded_at), + receipt_sha256=row.receipt_sha256, + authority_provenance=dict(row.authority_provenance), + replayed=replayed, + ) + + +def _read_replay( + session: Session, + tenant_id: str, + operation: str, + key: str, + request: Mapping[str, Any], +) -> Mapping[str, Any] | None: + normalized = _idempotency(key) + row = ( + session.query(ApprovalReplay) + .filter( + ApprovalReplay.tenant_id == tenant_id, + ApprovalReplay.operation == operation, + ApprovalReplay.idempotency_key == normalized, + ) + .one_or_none() + ) + if row is None: + return None + if row.request_sha256 != _sha256(request): + raise ApprovalStoreError( + "Approval idempotency key was reused for another command." + ) + return dict(row.response) + + +def _write_replay( + session: Session, + tenant_id: str, + operation: str, + key: str, + request: Mapping[str, Any], + response: Mapping[str, Any], +) -> None: + session.add( + ApprovalReplay( + tenant_id=tenant_id, + operation=operation, + idempotency_key=_idempotency(key), + request_sha256=_sha256(request), + response=dict(response), + ) + ) + session.flush() + + +def _sha256(value: object) -> str: + return hashlib.sha256( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + default=lambda item: ( + _iso(item) if isinstance(item, datetime) else str(item) + ), + ).encode() + ).hexdigest() + + +def _idempotency(value: str) -> str: + normalized = str(value or "").strip() + if not normalized or len(normalized) > 160: + raise ApprovalStoreError( + "Approval idempotency key is required and limited to 160 characters." + ) + return normalized + + +def _tenant(principal: object) -> str: + value = str(getattr(principal, "tenant_id", "") or "").strip() + if not value: + raise ApprovalStoreError( + "Approval operations require a tenant-bound principal." + ) + return value + + +def _actor(principal: object) -> str | None: + for name in ("account_id", "identity_id", "membership_id"): + value = _text(getattr(principal, name, None)) + if value: + return value + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise ApprovalStoreError("Approvals requires a database session.") + return value # type: ignore[return-value] + + +def _text(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _datetime(value: object) -> datetime | None: + if value in (None, ""): + return None + parsed = ( + value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + ) + return _aware(parsed) + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _iso(value: datetime | None) -> str | None: + return _aware(value).isoformat() if value is not None else None + + +def _now() -> datetime: + return datetime.now(UTC) + + +__all__ = ["ApprovalStoreError", "SqlApprovalRequests"] diff --git a/tests/test_approvals.py b/tests/test_approvals.py new file mode 100644 index 0000000..0817fb7 --- /dev/null +++ b/tests/test_approvals.py @@ -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() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 910680c..d481c3e 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..10529d6 --- /dev/null +++ b/tests/test_migrations.py @@ -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() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..f38f5f2 --- /dev/null +++ b/webui/package.json @@ -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 } } +} diff --git a/webui/src/api/approvals.ts b/webui/src/api/approvals.ts new file mode 100644 index 0000000..7a3dd11 --- /dev/null +++ b/webui/src/api/approvals.ts @@ -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 }; +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; + template_id?: string | null; + template_revision?: number | null; + requested_by?: string | null; + completed_at?: string | null; + metadata: Record; +}; +export type ApprovalDraft = Omit; +export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record }; + +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 { + return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}`, { signal }); +} + +export function approvalHistory(settings: ApiSettings, id: string, signal?: AbortSignal): Promise { + return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}/history`, { signal }); +} + +export function createApproval(settings: ApiSettings, request: ApprovalDraft): Promise { + 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): 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 }) + }); +} diff --git a/webui/src/features/approvals/ApprovalRequestDialog.tsx b/webui/src/features/approvals/ApprovalRequestDialog.tsx new file mode 100644 index 0000000..0153b27 --- /dev/null +++ b/webui/src/features/approvals/ApprovalRequestDialog.tsx @@ -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(() => 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) { + setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) })); + } + + return }> +
+ {error && {error}} +
+ setDraft({ ...draft, title: event.target.value })} /> + setDraft({ ...draft, subject_module: event.target.value })} /> + setDraft({ ...draft, subject_type: event.target.value })} /> + setDraft({ ...draft, subject_id: event.target.value })} /> + setDraft({ ...draft, subject_version: event.target.value })} /> + setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /> +