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