Implement generic approval runtime

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 2b081cb931
commit 62d0f300cb
23 changed files with 3133 additions and 118 deletions
@@ -0,0 +1,13 @@
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalReplay,
)
__all__ = [
"ApprovalDecisionRecord",
"ApprovalLifecycleEvent",
"ApprovalReplay",
"ApprovalRequestRevision",
]
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class ApprovalRequestRevision(Base, TimestampMixin):
__tablename__ = "approval_request_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "request_id", "revision", name="uq_approval_request_revision"
),
Index(
"ix_approval_request_current", "tenant_id", "request_id", "superseded_at"
),
Index(
"ix_approval_request_subject",
"tenant_id",
"subject_module",
"subject_type",
"subject_id",
"state",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("approval_request_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
current_step_key: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
subject_module: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
subject_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject_version: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
subject_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class ApprovalDecisionRecord(Base, TimestampMixin):
__tablename__ = "approval_decision_records"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"request_id",
"step_key",
"effective_actor_id",
name="uq_approval_step_actor",
),
UniqueConstraint(
"tenant_id",
"request_id",
"idempotency_key",
name="uq_approval_decision_replay",
),
Index("ix_approval_decision_history", "tenant_id", "request_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_revision: Mapped[int] = mapped_column(Integer, nullable=False)
step_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
outcome: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
reason: Mapped[str] = mapped_column(String(4000), nullable=False)
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
effective_actor_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
authority_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, nullable=False, default=dict
)
signature_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
class ApprovalLifecycleEvent(Base, TimestampMixin):
__tablename__ = "approval_lifecycle_events"
__table_args__ = (
UniqueConstraint(
"tenant_id", "request_id", "sequence", name="uq_approval_event_sequence"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
class ApprovalReplay(Base, TimestampMixin):
__tablename__ = "approval_replays"
__table_args__ = (
UniqueConstraint(
"tenant_id", "operation", "idempotency_key", name="uq_approval_replay"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
operation: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
class ApprovalTemplateRevision(Base, TimestampMixin):
__tablename__ = "approval_template_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "template_id", "revision", name="uq_approval_template_revision"
),
UniqueConstraint(
"tenant_id", "key", "revision", name="uq_approval_template_key_revision"
),
Index(
"ix_approval_template_current", "tenant_id", "template_id", "superseded_at"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
template_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("approval_template_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
__all__ = [
"ApprovalDecisionRecord",
"ApprovalLifecycleEvent",
"ApprovalReplay",
"ApprovalRequestRevision",
"ApprovalTemplateRevision",
]
+228 -66
View File
@@ -1,21 +1,45 @@
from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.approvals import CAPABILITY_APPROVAL_REQUESTS
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_approvals.backend.db import models as approval_models
from govoplan_approvals.backend.service import SqlApprovalRequests
MODULE_ID = "approvals"
MODULE_NAME = "Approvals"
MODULE_VERSION = "0.1.8"
MODULE_VERSION = "0.1.14"
READ_SCOPE = "approvals:workspace:read"
WRITE_SCOPE = "approvals:workspace:write"
DECIDE_SCOPE = "approvals:workspace:decide"
ADMIN_SCOPE = "approvals:workspace:admin"
OPTIONAL_DEPENDENCIES = (
"workflow_engine",
"audit",
"files",
"notifications",
)
OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy")
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -24,7 +48,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
scope=scope,
label=label,
description=description,
category="Approvals",
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
@@ -32,56 +56,28 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
)
PERMISSIONS = (
_permission(READ_SCOPE, "View approvals workspace", "Read approvals records, configuration, and workflow context."),
_permission(WRITE_SCOPE, "Manage approvals workspace", "Create and update approvals records and workflow state."),
_permission(ADMIN_SCOPE, "Administer approvals workspace", "Configure approvals policies, templates, and tenant-level administration."),
)
def _router(_context: ModuleContext):
from govoplan_approvals.backend.router import router
ROLE_TEMPLATES = (
RoleTemplate(
slug="approvals_manager",
name="Approvals manager",
description="Manage approvals records and workflow state.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="approvals_viewer",
name="Approvals viewer",
description="Read approvals records and workflow context.",
permissions=(READ_SCOPE,),
),
)
return router
def _requests(_context: ModuleContext) -> SqlApprovalRequests:
return SqlApprovalRequests()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
approval_models.ApprovalRequestRevision.superseded_at.is_(None),
)
return {
"approval_requests": current.count(),
"approval_pending": current.filter(
approval_models.ApprovalRequestRevision.state.in_(("pending", "escalated"))
).count(),
}
DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary",
summary="Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.",
body=(
"This repository is currently a platform module seed. It registers the domain boundary, "
"permission surface, role templates, and documentation metadata before runtime APIs, "
"database models, migrations, and WebUI routes are introduced."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
order=100,
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Repository domain boundary",
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"seed": True,
"domain_objects": ['approval requests', 'sign-off chains', 'delegation and substitution facts', 'four-eyes constraints', 'escalation state', 'signature references'],
"first_slice": "Define reusable approval request, step, actor, delegation, substitution, and decision result contracts for consuming modules.",
},
),
)
manifest = ModuleManifest(
id=MODULE_ID,
@@ -89,18 +85,184 @@ manifest = ModuleManifest(
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION,
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_APPROVAL_REQUESTS, version="0.1.0"),
),
permissions=(
_permission(
READ_SCOPE,
"View approval requests",
"Read approval chains, current gates, outcomes, and history.",
),
_permission(
WRITE_SCOPE,
"Request approvals",
"Create immutable approval chains for exact subject revisions.",
),
_permission(
DECIDE_SCOPE,
"Decide approvals",
"Approve or reject eligible approval steps.",
),
_permission(
ADMIN_SCOPE,
"Administer approvals",
"Escalate due approvals and configure approval policies.",
),
),
role_templates=(
RoleTemplate(
slug="approvals_manager",
name="Approvals manager",
description="Create and manage approval requests.",
permissions=(READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE),
),
RoleTemplate(
slug="approver",
name="Approver",
description="Read and decide eligible approval steps.",
permissions=(READ_SCOPE, DECIDE_SCOPE),
),
RoleTemplate(
slug="approvals_admin",
name="Approvals administrator",
description="Administer approval policies and escalation.",
permissions=(READ_SCOPE, WRITE_SCOPE, DECIDE_SCOPE, ADMIN_SCOPE),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/approvals",
label="Approvals",
icon="list-checks",
required_any=(READ_SCOPE,),
order=37,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/approvals-webui",
routes=(
FrontendRoute(
path="/approvals",
component="ApprovalsPage",
required_any=(READ_SCOPE,),
order=37,
),
),
nav_items=(
NavItem(
path="/approvals",
label="Approvals",
icon="list-checks",
required_any=(READ_SCOPE,),
order=37,
),
),
view_surfaces=(
ViewSurface(
id="approvals.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Approvals navigation",
order=10,
),
ViewSurface(
id="approvals.workspace",
module_id=MODULE_ID,
kind="route",
label="Approval request workspace",
order=20,
),
),
),
capability_factories={CAPABILITY_APPROVAL_REQUESTS: _requests},
capability_documentation={
CAPABILITY_APPROVAL_REQUESTS: CapabilityDocumentation(
label="Governed approval requests",
summary="Freezes exact subject approval chains and resolves auditable sequential decisions.",
contract_version="0.1.0",
)
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
approval_models.ApprovalReplay,
approval_models.ApprovalLifecycleEvent,
approval_models.ApprovalDecisionRecord,
approval_models.ApprovalRequestRevision,
approval_models.ApprovalTemplateRevision,
label=MODULE_NAME,
),
retirement_notes="Destructive retirement requires a verified snapshot and removes approval chains, decisions, signature references, and lifecycle evidence.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
approval_models.ApprovalRequestRevision,
approval_models.ApprovalDecisionRecord,
approval_models.ApprovalLifecycleEvent,
approval_models.ApprovalReplay,
approval_models.ApprovalTemplateRevision,
label=MODULE_NAME,
),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="approvals.module-boundary",
title="Governed approval chains",
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
body=(
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. "
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Approvals boundary and recovery",
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="governance",
maturity="scaffold",
maturity="vertical_slice",
documentation_ref="docs/APPROVALS_DOMAIN_BOUNDARY.md",
known_limits=("Runtime approval lifecycles and persistence are not implemented yet.",),
owned_concepts=("approval request", "approval chain", "approval decision"),
non_owned_concepts=("workflow execution", "identity", "document signature"),
test_ref="tests/test_approvals.py",
known_limits=(
"Policy-authored template selection and cryptographic signature providers remain optional product depth; signature references are evidence pointers, not a cryptographic claim.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
"approval request",
"approval chain",
"approval decision",
"approval escalation",
),
non_owned_concepts=(
"workflow execution",
"identity",
"document signature",
"module business outcome",
),
migration_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
recovery_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
security_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
operations_docs=("docs/APPROVALS_DOMAIN_BOUNDARY.md",),
),
)
@@ -0,0 +1 @@
"""Approvals migrations."""
@@ -0,0 +1,254 @@
"""v0.1.14 Approvals runtime.
Revision ID: 8b9c0d1e2f3a
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "8b9c0d1e2f3a"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"approval_request_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("current_step_key", sa.String(length=120), nullable=True),
sa.Column("subject_module", sa.String(length=120), nullable=False),
sa.Column("subject_type", sa.String(length=120), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("subject_version", sa.String(length=120), nullable=True),
sa.Column("subject_digest", sa.String(length=64), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["approval_request_revisions.id"],
name=op.f(
"fk_approval_request_revisions_previous_revision_id_approval_request_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_request_revisions")),
sa.UniqueConstraint(
"tenant_id", "request_id", "revision", name="uq_approval_request_revision"
),
)
for column in (
"tenant_id",
"request_id",
"previous_revision_id",
"state",
"current_step_key",
"subject_module",
"subject_type",
"subject_id",
"subject_version",
"subject_digest",
"recorded_at",
"superseded_at",
"actor_id",
):
op.create_index(
op.f(f"ix_approval_request_revisions_{column}"),
"approval_request_revisions",
[column],
unique=False,
)
op.create_index(
"ix_approval_request_current",
"approval_request_revisions",
["tenant_id", "request_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_approval_request_subject",
"approval_request_revisions",
["tenant_id", "subject_module", "subject_type", "subject_id", "state"],
unique=False,
)
op.create_table(
"approval_decision_records",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("request_revision", sa.Integer(), nullable=False),
sa.Column("step_key", sa.String(length=120), nullable=False),
sa.Column("outcome", sa.String(length=20), nullable=False),
sa.Column("reason", sa.String(length=4000), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=False),
sa.Column("effective_actor_id", sa.String(length=255), nullable=False),
sa.Column("delegation_id", sa.String(length=255), nullable=True),
sa.Column("authority_provenance", sa.JSON(), nullable=False),
sa.Column("signature_ref", sa.JSON(), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("receipt_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_decision_records")),
sa.UniqueConstraint(
"tenant_id",
"request_id",
"step_key",
"effective_actor_id",
name="uq_approval_step_actor",
),
sa.UniqueConstraint(
"tenant_id",
"request_id",
"idempotency_key",
name="uq_approval_decision_replay",
),
)
for column in (
"tenant_id",
"request_id",
"step_key",
"outcome",
"actor_id",
"effective_actor_id",
"recorded_at",
"receipt_sha256",
):
op.create_index(
op.f(f"ix_approval_decision_records_{column}"),
"approval_decision_records",
[column],
unique=False,
)
op.create_index(
"ix_approval_decision_history",
"approval_decision_records",
["tenant_id", "request_id", "recorded_at"],
unique=False,
)
op.create_table(
"approval_lifecycle_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=60), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_lifecycle_events")),
sa.UniqueConstraint(
"tenant_id", "request_id", "sequence", name="uq_approval_event_sequence"
),
)
for column in ("tenant_id", "request_id", "event_type", "recorded_at"):
op.create_index(
op.f(f"ix_approval_lifecycle_events_{column}"),
"approval_lifecycle_events",
[column],
unique=False,
)
op.create_table(
"approval_replays",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("operation", sa.String(length=80), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("response", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_replays")),
sa.UniqueConstraint(
"tenant_id", "operation", "idempotency_key", name="uq_approval_replay"
),
)
for column in ("tenant_id", "operation"):
op.create_index(
op.f(f"ix_approval_replays_{column}"),
"approval_replays",
[column],
unique=False,
)
op.create_table(
"approval_template_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=False),
sa.Column("key", sa.String(length=120), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("content_sha256", sa.String(length=64), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["approval_template_revisions.id"],
name=op.f(
"fk_approval_template_revisions_previous_revision_id_approval_template_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_template_revisions")),
sa.UniqueConstraint(
"tenant_id", "template_id", "revision", name="uq_approval_template_revision"
),
sa.UniqueConstraint(
"tenant_id", "key", "revision", name="uq_approval_template_key_revision"
),
)
for column in (
"tenant_id",
"template_id",
"key",
"previous_revision_id",
"state",
"content_sha256",
"recorded_at",
"superseded_at",
"actor_id",
):
op.create_index(
op.f(f"ix_approval_template_revisions_{column}"),
"approval_template_revisions",
[column],
unique=False,
)
op.create_index(
"ix_approval_template_current",
"approval_template_revisions",
["tenant_id", "template_id", "superseded_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("approval_template_revisions")
op.drop_table("approval_replays")
op.drop_table("approval_lifecycle_events")
op.drop_table("approval_decision_records")
op.drop_table("approval_request_revisions")
@@ -0,0 +1 @@
"""Approvals migration revisions."""
+305
View File
@@ -0,0 +1,305 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_approvals.backend.schemas import (
ApprovalCreateRequest,
ApprovalDecisionInput,
ApprovalListResponse,
ApprovalTemplateCreateRequest,
ApprovalTemplateReviseRequest,
ApprovalTransitionInput,
)
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
router = APIRouter(prefix="/approvals", tags=["approvals"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, LookupError):
return HTTPException(status_code=404, detail=str(exc))
text = str(exc)
return HTTPException(
status_code=409
if "conflict" in text.lower() or "idempotency" in text.lower()
else 400,
detail=text,
)
@router.get("", response_model=ApprovalListResponse)
def api_list_requests(
request_state: str | None = Query(default=None, alias="state"),
subject_module: str | None = None,
subject_id: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ApprovalListResponse:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
return ApprovalListResponse(
requests=list(
SqlApprovalRequests().list_requests(
session,
principal,
state=request_state,
subject_module=subject_module,
subject_id=subject_id,
limit=limit,
)
)
)
@router.get("/templates", response_model=list[dict[str, Any]])
def api_list_templates(
template_state: str | None = Query(default=None, alias="state"),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[dict[str, Any]]:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
return [
dict(item)
for item in SqlApprovalRequests().list_templates(
session, principal, state=template_state, limit=limit
)
]
@router.post(
"/templates", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED
)
def api_create_template(
payload: ApprovalTemplateCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
result = SqlApprovalRequests().create_template(
session,
principal,
command=payload.template.to_command(),
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_template(
session, principal, template_id=result.id
)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.put("/templates/{template_id}", response_model=dict[str, Any])
def api_revise_template(
template_id: str,
payload: ApprovalTemplateReviseRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
result = SqlApprovalRequests().revise_template(
session,
principal,
template_id=template_id,
command=payload.template.to_command(),
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_template(
session, principal, template_id=result.id
)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/templates/{template_id}/publish", response_model=dict[str, Any])
def api_publish_template(
template_id: str,
payload: ApprovalTransitionInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
result = SqlApprovalRequests().publish_template(
session,
principal,
template_id=template_id,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_template(
session, principal, template_id=result.id
)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.get("/{request_id}", response_model=dict[str, Any])
def api_get_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
item = SqlApprovalRequests().get_request(session, principal, request_id=request_id)
if item is None:
raise HTTPException(status_code=404, detail="Approval request not found")
return dict(item)
@router.get("/{request_id}/history", response_model=list[dict[str, Any]])
def api_get_history(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[dict[str, Any]]:
from govoplan_approvals.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
if (
SqlApprovalRequests().get_request(session, principal, request_id=request_id)
is None
):
raise HTTPException(status_code=404, detail="Approval request not found")
return [
dict(item)
for item in SqlApprovalRequests().history(
session, principal, request_id=request_id
)
]
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
def api_create_request(
payload: ApprovalCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import WRITE_SCOPE
_require(principal, WRITE_SCOPE)
try:
result = SqlApprovalRequests().create_request(
session,
principal,
command=payload.request.to_command(),
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_request(session, principal, request_id=result.id)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{request_id}/decisions", response_model=dict[str, Any])
def api_decide(
request_id: str,
payload: ApprovalDecisionInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import DECIDE_SCOPE
_require(principal, DECIDE_SCOPE)
try:
receipt = SqlApprovalRequests().decide(
session, principal, request_id=request_id, command=payload.to_command()
)
session.commit()
return {
"receipt": {
"request_id": receipt.request_id,
"revision": receipt.revision,
"step_key": receipt.step_key,
"outcome": receipt.outcome,
"actor_id": receipt.actor_id,
"recorded_at": receipt.recorded_at,
"receipt_sha256": receipt.receipt_sha256,
"replayed": receipt.replayed,
},
"request": dict(
SqlApprovalRequests().get_request(
session, principal, request_id=request_id
)
or {}
),
}
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{request_id}/escalate", response_model=dict[str, Any])
def api_escalate(
request_id: str,
payload: ApprovalTransitionInput,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_approvals.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
try:
SqlApprovalRequests().escalate_due(
session,
principal,
request_id=request_id,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
SqlApprovalRequests().get_request(session, principal, request_id=request_id)
or {}
)
except (ApprovalStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
__all__ = ["router"]
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalDecisionCommand,
ApprovalRequestCreateCommand,
ApprovalStepDefinition,
ApprovalTemplateCreateCommand,
)
class ApprovalSelectorInput(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["account", "group", "role", "function_assignment", "any_account"]
value: str = Field(min_length=1, max_length=255)
label: str | None = Field(default=None, max_length=255)
class ApprovalStepInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
selectors: list[ApprovalSelectorInput] = Field(min_length=1, max_length=500)
required_approvals: int = Field(default=1, ge=1, le=500)
rejection_policy: Literal["fail_fast", "collect"] = "fail_fast"
due_at: datetime | None = None
signature_required: bool = False
forbidden_evidence_roles: list[str] = Field(default_factory=list, max_length=100)
metadata: dict[str, Any] = Field(default_factory=dict)
def to_definition(self) -> ApprovalStepDefinition:
return ApprovalStepDefinition(
key=self.key,
label=self.label,
selectors=tuple(
ApprovalActorSelector(item.kind, item.value, item.label)
for item in self.selectors
),
required_approvals=self.required_approvals,
rejection_policy=self.rejection_policy,
due_at=self.due_at,
signature_required=self.signature_required,
forbidden_evidence_roles=tuple(self.forbidden_evidence_roles),
metadata=self.metadata,
)
class ApprovalRequestInput(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10_000)
subject_module: str = Field(min_length=1, max_length=120)
subject_type: str = Field(min_length=1, max_length=120)
subject_id: str = Field(min_length=1, max_length=255)
subject_version: str | None = Field(default=None, max_length=120)
subject_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
steps: list[ApprovalStepInput] = Field(default_factory=list, max_length=100)
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
expires_at: datetime | None = None
policy_refs: list[str] = Field(default_factory=list, max_length=500)
evidence_actors: dict[str, list[str]] = Field(default_factory=dict)
template_id: str | None = Field(default=None, max_length=36)
template_revision: int | None = Field(default=None, ge=1)
metadata: dict[str, Any] = Field(default_factory=dict)
def to_command(self) -> ApprovalRequestCreateCommand:
return ApprovalRequestCreateCommand(
title=self.title,
description=self.description,
subject_module=self.subject_module,
subject_type=self.subject_type,
subject_id=self.subject_id,
subject_version=self.subject_version,
subject_digest=self.subject_digest,
steps=tuple(step.to_definition() for step in self.steps),
separation_of_duties=self.separation_of_duties,
unique_actors_across_steps=self.unique_actors_across_steps,
expires_at=self.expires_at,
policy_refs=tuple(self.policy_refs),
evidence_actors={
key: tuple(values) for key, values in self.evidence_actors.items()
},
template_id=self.template_id,
template_revision=self.template_revision,
metadata=self.metadata,
)
class ApprovalTemplateInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10_000)
steps: list[ApprovalStepInput] = Field(min_length=1, max_length=100)
separation_of_duties: bool = True
unique_actors_across_steps: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
def to_command(self) -> ApprovalTemplateCreateCommand:
return ApprovalTemplateCreateCommand(
key=self.key,
title=self.title,
description=self.description,
steps=tuple(step.to_definition() for step in self.steps),
separation_of_duties=self.separation_of_duties,
unique_actors_across_steps=self.unique_actors_across_steps,
metadata=self.metadata,
)
class ApprovalTemplateCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
template: ApprovalTemplateInput
idempotency_key: str = Field(min_length=1, max_length=160)
class ApprovalTemplateReviseRequest(ApprovalTemplateCreateRequest):
expected_revision: int = Field(ge=1)
class ApprovalCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
request: ApprovalRequestInput
idempotency_key: str = Field(min_length=1, max_length=160)
class ApprovalDecisionInput(BaseModel):
model_config = ConfigDict(extra="forbid")
outcome: Literal["approved", "rejected"]
reason: str = Field(min_length=1, max_length=4000)
expected_revision: int = Field(ge=1)
idempotency_key: str = Field(min_length=1, max_length=160)
delegated_for_account_id: str | None = Field(default=None, max_length=255)
signature_ref: dict[str, Any] | None = None
def to_command(self) -> ApprovalDecisionCommand:
return ApprovalDecisionCommand(
outcome=self.outcome,
reason=self.reason,
expected_revision=self.expected_revision,
idempotency_key=self.idempotency_key,
delegated_for_account_id=self.delegated_for_account_id,
signature_ref=self.signature_ref,
)
class ApprovalTransitionInput(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
idempotency_key: str = Field(min_length=1, max_length=160)
class ApprovalListResponse(BaseModel):
requests: list[dict[str, Any]]
__all__ = [
"ApprovalCreateRequest",
"ApprovalDecisionInput",
"ApprovalListResponse",
"ApprovalTransitionInput",
"ApprovalTemplateCreateRequest",
"ApprovalTemplateReviseRequest",
]
File diff suppressed because it is too large Load Diff