11 Commits
Author SHA1 Message Date
zemion ad9a580ad3 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:51:58 +02:00
zemion 1ac44af8d0 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:12 +02:00
zemion 9bea71d87e Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion 532e614f7b Harden module package publication 2026-08-04 14:02:39 +02:00
zemion b7c2411819 Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 7f64247625 Add approval template revision administration 2026-08-04 01:04:39 +02:00
zemion 3abbe117e5 Assign Approvals a unique migration revision 2026-08-03 15:04:39 +02:00
zemion 24e95591e0 Migrate Approvals interface patterns 2026-08-03 12:55:27 +02:00
zemion 62d0f300cb Implement generic approval runtime 2026-08-01 20:57:26 +02:00
zemion 2b081cb931 docs: declare institutional architecture boundary 2026-08-01 17:48:22 +02:00
zemion 805ac1c8de refactor: target workflow engine runtime 2026-07-31 16:59:21 +02:00
32 changed files with 4531 additions and 115 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Approvals Codex Guide # GovOPlaN Approvals Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Approvals internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope ## Scope
This repository owns the GovOPlaN Approvals platform module seed. This repository owns the GovOPlaN Approvals platform module seed.
+17 -6
View File
@@ -4,9 +4,7 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-approvals` is the GovOPlaN platform module seed for generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures. `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.
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.
## Initial Ownership ## Initial Ownership
@@ -27,11 +25,24 @@ This module does not own:
Detailed boundary notes are in [docs/APPROVALS_DOMAIN_BOUNDARY.md](docs/APPROVALS_DOMAIN_BOUNDARY.md). Detailed boundary notes are in [docs/APPROVALS_DOMAIN_BOUNDARY.md](docs/APPROVALS_DOMAIN_BOUNDARY.md).
Tenant approval administrators manage reusable chains under
`Admin > Tenant > Approval templates`. Template edits and publication create
immutable revisions with content hashes and actor provenance. The history API
and UI compare any two revisions as structured JSON-pointer changes. The
request workspace also exposes due-step escalation to approval administrators;
the server rechecks the due timestamp and request revision before recording the
transition.
Template API additions:
- `GET /api/v1/approvals/templates/{template_id}/history`
- `GET /api/v1/approvals/templates/{template_id}/compare`
## Integrations ## Integrations
Expected optional integrations: Optional integrations:
- workflow - workflow engine
- audit - audit
- files - files
- notifications - notifications
@@ -45,7 +56,7 @@ cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-approvals ./.venv/bin/python -m pip install -e ../govoplan-approvals
``` ```
Focused manifest verification: Focused runtime verification:
```bash ```bash
cd /mnt/DATA/git/govoplan-approvals cd /mnt/DATA/git/govoplan-approvals
+39 -33
View File
@@ -1,44 +1,50 @@
# Approvals Domain Boundary # Approvals domain boundary and operations
## Purpose Approvals owns generic, reusable sign-off chains. A request freezes an exact
module-owned subject revision, ordered steps, eligible actor selectors,
required counts, rejection behavior, due dates, signature requirements,
separation-of-duties rules, and policy references.
Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures. Approvals does not own the subject's business state. A Campaign remains a
Campaign and a Decision remains a Decision. Consumers call
`approvals.requests.check_approved` with the exact subject identity and version
before performing their consequential transition. A previously approved
request cannot authorize a changed subject revision.
## Owns ## Actor and decision semantics
- approval requests Selectors can target an account, group, role, function assignment, or any
- sign-off chains authenticated account. They are evaluated against trusted principal claims.
- delegation and substitution facts Delegated decisions are accepted only when the principal already carries the
- four-eyes constraints matching acting-for account. The actual and represented account plus the
- escalation state trusted delegation identifier are retained.
- signature references
## 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 ## Recovery and scale-out
- workflow orchestration engine
- identity and RBAC primitives
## Integration Candidates All API and worker nodes use the logically shared database. Back up and restore
these tables as one consistency unit:
- workflow - `approval_request_revisions`
- audit - `approval_decision_records`
- files - `approval_lifecycle_events`
- notifications - `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 Workflow Engine may wait for completion and Notifications may announce an
- tenant-level permission definitions assignment, due date, escalation, or outcome. Audit may retain additional
- manager and viewer role templates cross-domain evidence. Policy may provide chain templates. These integrations
- documentation topic describing the module boundary use capabilities and events; none reads Approval tables directly.
- 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.
+30
View File
@@ -0,0 +1,30 @@
# Approvals Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the
Approvals-owned route without changing the append-only approval model or
importing optional sibling modules.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/approvals` list | Searchable work queue | Select request or create immutable chain | Shared loading, empty, error, permission, selection, and help states |
| Approval detail | Governed record detail | Inspect exact subject, chain, state, and evidence | Locale-aware history, status, provenance, explained lifecycle actions |
| Request dialog | Consequential definition editor | Freeze exact subject and approval chain | Guarded draft, field help, validation reasons, at least one step |
| Decision dialog | Governed decision | Append approval or rejection evidence | Required reason/signature, guarded draft, explicit confirmation action |
## Consequence And Availability Rules
- Creation freezes the subject identity and digest plus every ordered step,
selector, quorum, separation, and signature requirement.
- Approving or rejecting appends attributable evidence and cannot be edited.
- Only pending or escalated requests can be decided. Terminal requests remain
available for reconstruction.
- Missing create or decide permission is visible and points to the Access role
assignment destination and responsible administrator.
- Signature references are evidence pointers and never a cryptographic claim.
The module uses Core dialogs, controls, status, blockers, help, loading, empty,
error, and draft-guard contracts. Native selection buttons preserve keyboard
order; bounded list/detail viewports remain responsive. English and German
catalogues cover module-owned copy and dates follow the active platform locale.
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "@govoplan/approvals", "name": "@govoplan/approvals",
"version": "0.1.8", "version": "0.1.16",
"private": true, "private": true,
"description": "GovOPlaN Approvals platform module seed.", "description": "Governed approval chains, decisions, delegation, and escalation for GovOPlaN.",
"type": "module", "type": "module",
"peerDependencies": {} "peerDependencies": {}
} }
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-approvals" name = "govoplan-approvals"
version = "0.1.8" version = "0.1.16"
description = "GovOPlaN Approvals platform module seed." description = "Governed approval chains, decisions, delegation, and escalation for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.16",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.16",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -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",
]
+326 -61
View File
@@ -1,20 +1,45 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from pathlib import Path
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
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_ID = "approvals"
MODULE_NAME = "Approvals" MODULE_NAME = "Approvals"
MODULE_VERSION = "0.1.8" MODULE_VERSION = "0.1.16"
READ_SCOPE = "approvals:workspace:read" READ_SCOPE = "approvals:workspace:read"
WRITE_SCOPE = "approvals:workspace:write" WRITE_SCOPE = "approvals:workspace:write"
DECIDE_SCOPE = "approvals:workspace:decide"
ADMIN_SCOPE = "approvals:workspace:admin" ADMIN_SCOPE = "approvals:workspace:admin"
OPTIONAL_DEPENDENCIES = ( OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy")
"workflow",
"audit",
"files",
"notifications",
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -23,7 +48,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
scope=scope, scope=scope,
label=label, label=label,
description=description, description=description,
category="Approvals", category=MODULE_NAME,
level="tenant", level="tenant",
module_id=module_id, module_id=module_id,
resource=resource, resource=resource,
@@ -31,56 +56,28 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
) )
PERMISSIONS = ( def _router(_context: ModuleContext):
_permission(READ_SCOPE, "View approvals workspace", "Read approvals records, configuration, and workflow context."), from govoplan_approvals.backend.router import router
_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."),
)
ROLE_TEMPLATES = ( return router
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,),
),
)
DOCUMENTATION = (
DocumentationTopic( def _requests(_context: ModuleContext) -> SqlApprovalRequests:
id=f"{MODULE_ID}.module-boundary", return SqlApprovalRequests()
title=f"{MODULE_NAME} module boundary",
summary="Generic approval and sign-off chains with delegation, substitution, four-eyes principle, escalation, and signatures.",
body=( def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
"This repository is currently a platform module seed. It registers the domain boundary, " current = session.query(approval_models.ApprovalRequestRevision).filter(
"permission surface, role templates, and documentation metadata before runtime APIs, " approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
"database models, migrations, and WebUI routes are introduced." approval_models.ApprovalRequestRevision.superseded_at.is_(None),
),
layer="available",
documentation_types=("admin",),
audience=("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.",
},
),
) )
return {
"approval_requests": current.count(),
"approval_pending": current.filter(
approval_models.ApprovalRequestRevision.state.in_(("pending", "escalated"))
).count(),
}
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
@@ -88,10 +85,278 @@ manifest = ModuleManifest(
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("access",), dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES, optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(
permissions=PERMISSIONS, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
role_templates=ROLE_TEMPLATES, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
documentation=DOCUMENTATION, ),
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,
),
ViewSurface(
id="approvals.admin.templates",
module_id=MODULE_ID,
kind="section",
label="Approval templates",
order=30,
),
),
),
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",
),
),
metadata={
"seed": True,
"help_contexts": [
"approvals.navigation",
"approvals.workspace",
"approvals.admin.templates",
"approvals.state.permission-blocked",
"approvals.state.empty",
],
"privacy_notes": [
"Approval lists and histories remain tenant-bound and permission-filtered.",
"Signature references identify evidence but do not expose private key material.",
"Decision history retains actor and reason as governed evidence.",
],
},
),
DocumentationTopic(
id="approvals.reference.fields-and-consequences",
title="Approval fields and consequences",
summary="Exact-subject identity, selector, separation-of-duty, signature, and decision consequences.",
body=(
"Subject module, type, identifier, version, and SHA-256 digest freeze the exact object revision being approved. "
"Ordered steps, actor selectors, required counts, requester separation, unique actors, and signature requirements "
"are copied into the immutable request and do not follow later template changes. Actor values are provider-neutral "
"identifiers interpreted through Access and IDM contracts. Approval or rejection appends a decision with actor, "
"reason, optional signature reference, and optimistic-concurrency revision. Completed, rejected, cancelled, and "
"expired requests remain evidence and cannot be decided again."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Approvals boundary and recovery",
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"seed": True,
"help_contexts": [
"approvals.field.subject-reference",
"approvals.field.subject-digest",
"approvals.field.actor-selector",
"approvals.field.separation-of-duties",
"approvals.field.signature-reference",
"approvals.action.create-request",
"approvals.action.decide-request",
],
"consequence_classes": {
"create_request": "Freezes an exact subject and immutable approval chain.",
"approve_step": "Appends an attributable decision and may advance or complete the chain.",
"reject_request": "Appends a rejection and completes the request according to its frozen policy.",
"retain_evidence": "Keeps request revisions, decisions, reasons, and signature references for reconstruction.",
},
},
),
DocumentationTopic(
id="approvals.workflow.administer-templates",
title="Administer approval templates",
summary="Create reusable approval chains, publish immutable revisions, compare history, and escalate steps only after their configured due time.",
body=(
"Approval administrators manage templates under Admin > Tenant > Approval templates. A stable key identifies the template while every edit creates a new draft revision with its own content hash, actor, predecessor, and timestamp. Publishing creates another immutable revision that new requests can bind to exactly; existing requests never follow later template changes. "
"The history dialog compares any two tenant-visible revisions as deterministic JSON-pointer changes without hiding unchanged evidence. Request operators with approval administration permission see Escalate only for pending requests, and the action becomes available after the current step's due time. The backend rechecks the due time and optimistic-concurrency revision before recording the lifecycle transition."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner", "auditor"),
links=(
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"),
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"),
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"),
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"),
),
metadata={
"help_contexts": [
"approvals.admin.templates",
"approvals.action.escalate-request",
],
"consequence_classes": {
"revise_template": "Supersedes the current template and creates a new draft revision.",
"publish_template": "Creates an immutable published revision available to new requests.",
"escalate_request": "Records that the current due step entered escalation without deciding it.",
},
},
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="governance",
maturity="vertical_slice",
documentation_ref="docs/APPROVALS_DOMAIN_BOUNDARY.md",
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 @@
"""Approvals migration revisions."""
@@ -0,0 +1,254 @@
"""v0.1.14 Approvals runtime.
Revision ID: a91c4e72b5d8
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a91c4e72b5d8"
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")
+379
View File
@@ -0,0 +1,379 @@
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("/templates/{template_id}", response_model=dict[str, Any])
def api_get_template(
template_id: str,
revision: int | None = Query(default=None, ge=1),
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_template(
session,
principal,
template_id=template_id,
revision=revision,
)
if item is None:
raise HTTPException(status_code=404, detail="Approval template not found")
return dict(item)
@router.get(
"/templates/{template_id}/history",
response_model=list[dict[str, Any]],
)
def api_get_template_history(
template_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)
try:
return [
dict(item)
for item in SqlApprovalRequests().template_history(
session,
principal,
template_id=template_id,
)
]
except (ApprovalStoreError, LookupError) as exc:
raise _error(exc) from exc
@router.get(
"/templates/{template_id}/compare",
response_model=dict[str, Any],
)
def api_compare_template_revisions(
template_id: str,
from_revision: int = Query(ge=1),
to_revision: int = Query(ge=1),
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)
try:
return dict(
SqlApprovalRequests().compare_template_revisions(
session,
principal,
template_id=template_id,
from_revision=from_revision,
to_revision=to_revision,
)
)
except (ApprovalStoreError, LookupError) as exc:
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
+398
View File
@@ -0,0 +1,398 @@
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,
)
def test_template_history_and_structural_compare_are_tenant_bound(self) -> None:
original = ApprovalTemplateCreateCommand(
key="monthly-release",
title="Monthly release",
description="Initial process",
steps=(
ApprovalStepDefinition(
"review",
"Review",
(ApprovalActorSelector("role", "reviewers"),),
),
),
)
revised_command = ApprovalTemplateCreateCommand(
key="monthly-release",
title="Monthly release approval",
description="Initial process",
steps=(
ApprovalStepDefinition(
"review",
"Independent review",
(ApprovalActorSelector("role", "reviewers"),),
),
),
)
with self.Session() as session:
created = self.service.create_template(
session,
self.requester,
command=original,
idempotency_key="history-template-1",
)
revised = self.service.revise_template(
session,
self.requester,
template_id=created.id,
command=revised_command,
expected_revision=1,
idempotency_key="history-template-2",
)
self.assertEqual(2, revised.revision)
history = self.service.template_history(
session,
self.requester,
template_id=created.id,
)
self.assertEqual([2, 1], [item["revision"] for item in history])
self.assertIsNotNone(history[1]["superseded_at"])
self.assertEqual("requester", history[0]["actor_id"])
comparison = self.service.compare_template_revisions(
session,
self.requester,
template_id=created.id,
from_revision=1,
to_revision=2,
)
changes = {item["path"]: item for item in comparison["changes"]}
self.assertEqual(
"Monthly release",
changes["/title"]["before"],
)
self.assertEqual(
"Independent review",
changes["/steps/0/label"]["after"],
)
with self.assertRaises(LookupError):
self.service.template_history(
session,
Principal("tenant-2", "other"),
template_id=created.id,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from govoplan_approvals.backend.manifest import manifest
class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual({"/approvals"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
self.assertEqual(
{
"approvals.navigation",
"approvals.workspace",
"approvals.admin.templates",
},
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
)
def test_help_privacy_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
guide = topics["approvals.module-boundary"]
reference = topics["approvals.reference.fields-and-consequences"]
templates = topics["approvals.workflow.administer-templates"]
self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"])
self.assertIn("create_request", reference.metadata["consequence_classes"])
self.assertIn("reject_request", reference.metadata["consequence_classes"])
self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"])
if __name__ == "__main__":
unittest.main()
+20 -8
View File
@@ -2,22 +2,34 @@ from __future__ import annotations
import unittest 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): class ManifestTests(unittest.TestCase):
def test_manifest_registers_seed_contract(self) -> None: def test_manifest_registers_runtime_contract(self) -> None:
manifest = get_manifest() manifest = get_manifest()
self.assertEqual(manifest.id, "approvals") self.assertEqual(manifest.id, "approvals")
self.assertEqual(manifest.name, "Approvals") self.assertEqual(manifest.name, "Approvals")
self.assertEqual(manifest.dependencies, ("access",)) self.assertEqual(manifest.dependencies, ("access",))
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) self.assertEqual(
self.assertEqual({role.slug for role in manifest.role_templates}, {"approvals_manager", "approvals_viewer"}) {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.assertTrue(manifest.documentation)
self.assertIsNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend) self.assertIsNotNone(manifest.frontend)
if __name__ == "__main__": if __name__ == "__main__":
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_approvals.backend.manifest import get_manifest
from govoplan_core.db.migrations import migrate_database
class ApprovalsMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_approval_runtime_tables(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-approvals-") as directory:
url = f"sqlite:///{Path(directory) / 'approvals.db'}"
migrate_database(
database_url=url,
enabled_modules=("approvals",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
tables = set(inspect(engine).get_table_names())
self.assertTrue(
{
"approval_template_revisions",
"approval_request_revisions",
"approval_decision_records",
"approval_lifecycle_events",
"approval_replays",
}.issubset(tables)
)
with engine.connect() as connection:
self.assertIn(
"a91c4e72b5d8",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@govoplan/approvals-webui",
"version": "0.1.16",
"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.16",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"scripts": {
"test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs"
}
}
+122
View File
@@ -0,0 +1,122 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type ApprovalSelector = { kind: "account" | "group" | "role" | "function_assignment" | "any_account"; value: string; label?: string | null };
export type ApprovalStep = { key: string; label: string; selectors: ApprovalSelector[]; required_approvals: number; rejection_policy: "fail_fast" | "collect"; due_at?: string | null; signature_required: boolean; forbidden_evidence_roles: string[]; metadata: Record<string, unknown> };
export type ApprovalRequest = {
id: string;
revision: number;
state: "pending" | "escalated" | "approved" | "rejected" | "cancelled" | "expired";
current_step_key?: string | null;
title: string;
description?: string | null;
subject_module: string;
subject_type: string;
subject_id: string;
subject_version?: string | null;
subject_digest: string;
steps: ApprovalStep[];
current_step_index: number;
separation_of_duties: boolean;
unique_actors_across_steps: boolean;
expires_at?: string | null;
policy_refs: string[];
evidence_actors: Record<string, string[]>;
template_id?: string | null;
template_revision?: number | null;
requested_by?: string | null;
completed_at?: string | null;
metadata: Record<string, unknown>;
};
export type ApprovalDraft = Omit<ApprovalRequest, "id" | "revision" | "state" | "current_step_key" | "current_step_index" | "requested_by" | "completed_at">;
export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
export type ApprovalTemplate = {
id: string;
key: string;
title: string;
description?: string | null;
revision: number;
state: "draft" | "published";
content_sha256: string;
recorded_at: string;
superseded_at?: string | null;
previous_revision_id?: string | null;
actor_id?: string | null;
steps: ApprovalStep[];
separation_of_duties: boolean;
unique_actors_across_steps: boolean;
metadata: Record<string, unknown>;
};
export type ApprovalTemplateDraft = Pick<ApprovalTemplate, "key" | "title" | "description" | "steps" | "separation_of_duties" | "unique_actors_across_steps" | "metadata">;
export type ApprovalTemplateChange = { path: string; change: "added" | "removed" | "changed"; before: unknown; after: unknown };
export type ApprovalTemplateComparison = {
template_id: string;
from_revision: ApprovalTemplate;
to_revision: ApprovalTemplate;
changes: ApprovalTemplateChange[];
};
export function listApprovals(settings: ApiSettings, signal?: AbortSignal): Promise<{ requests: ApprovalRequest[] }> {
return apiFetch(settings, apiPath("/api/v1/approvals", { limit: 200 }), { signal });
}
export function getApproval(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalRequest> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}`, { signal });
}
export function approvalHistory(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalEvent[]> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}/history`, { signal });
}
export function createApproval(settings: ApiSettings, request: ApprovalDraft): Promise<ApprovalRequest> {
return apiFetch(settings, "/api/v1/approvals", { method: "POST", body: JSON.stringify({ request, idempotency_key: crypto.randomUUID() }) });
}
export function decideApproval(settings: ApiSettings, request: ApprovalRequest, outcome: "approved" | "rejected", reason: string, signatureRef?: Record<string, unknown>): Promise<{ request: ApprovalRequest }> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/decisions`, {
method: "POST",
body: JSON.stringify({ outcome, reason, expected_revision: request.revision, idempotency_key: crypto.randomUUID(), signature_ref: signatureRef ?? null })
});
}
export function escalateApproval(settings: ApiSettings, request: ApprovalRequest): Promise<ApprovalRequest> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/escalate`, {
method: "POST",
body: JSON.stringify({ expected_revision: request.revision, idempotency_key: crypto.randomUUID() })
});
}
export function listApprovalTemplates(settings: ApiSettings, signal?: AbortSignal): Promise<ApprovalTemplate[]> {
return apiFetch(settings, apiPath("/api/v1/approvals/templates", { limit: 200 }), { signal });
}
export function createApprovalTemplate(settings: ApiSettings, template: ApprovalTemplateDraft): Promise<ApprovalTemplate> {
return apiFetch(settings, "/api/v1/approvals/templates", {
method: "POST",
body: JSON.stringify({ template, idempotency_key: crypto.randomUUID() })
});
}
export function reviseApprovalTemplate(settings: ApiSettings, current: ApprovalTemplate, template: ApprovalTemplateDraft): Promise<ApprovalTemplate> {
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(current.id)}`, {
method: "PUT",
body: JSON.stringify({ template, expected_revision: current.revision, idempotency_key: crypto.randomUUID() })
});
}
export function publishApprovalTemplate(settings: ApiSettings, current: ApprovalTemplate): Promise<ApprovalTemplate> {
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(current.id)}/publish`, {
method: "POST",
body: JSON.stringify({ expected_revision: current.revision, idempotency_key: crypto.randomUUID() })
});
}
export function approvalTemplateHistory(settings: ApiSettings, templateId: string, signal?: AbortSignal): Promise<ApprovalTemplate[]> {
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(templateId)}/history`, { signal });
}
export function compareApprovalTemplateRevisions(settings: ApiSettings, templateId: string, fromRevision: number, toRevision: number, signal?: AbortSignal): Promise<ApprovalTemplateComparison> {
return apiFetch(settings, apiPath(`/api/v1/approvals/templates/${encodeURIComponent(templateId)}/compare`, {
from_revision: fromRevision,
to_revision: toRevision
}), { signal });
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui";
import { createApproval, type ApprovalDraft, type ApprovalRequest } from "../../api/approvals";
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
const [baseline] = useState<ApprovalDraft>(() => initialDraft());
const [draft, setDraft] = useState<ApprovalDraft>(baseline);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestDiscard } = useUnsavedChanges();
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]);
const dirty = draftKey(draft) !== draftKey(baseline);
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
onSaved(await createApproval(settings, draft));
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Approval request could not be created.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => setDraft(baseline),
title: "i18n:govoplan-approvals.unsaved_title",
message: "i18n:govoplan-approvals.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
return <Dialog open title="New Approval request" onClose={requestClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
<div className="approval-editor">
<div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="approval-editor-grid">
<FormField label="Title" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Subject module" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
<FormField label="Subject type" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
<FormField label="Subject ID" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
<FormField label="Subject version" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
<FormField label="Subject SHA-256" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</div>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div>
</Dialog>;
}
function initialDraft(): ApprovalDraft {
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyApprovalStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
}
function draftKey(draft: ApprovalDraft): string {
return JSON.stringify(draft);
}
@@ -0,0 +1,98 @@
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import {
Button,
DateTimeField,
FormField,
IconButton,
ToggleSwitch
} from "@govoplan/core-webui";
import type { ApprovalSelector, ApprovalStep } from "../../api/approvals";
import { APPROVALS_I18N } from "./interfacePatterns";
export default function ApprovalStepsEditor({
steps,
disabled,
onChange
}: {
steps: ApprovalStep[];
disabled?: boolean;
onChange: (steps: ApprovalStep[]) => void;
}) {
function patchStep(index: number, patch: Partial<ApprovalStep>) {
onChange(steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function moveStep(index: number, offset: -1 | 1) {
const target = index + offset;
if (target < 0 || target >= steps.length) return;
const next = [...steps];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
}
function patchSelector(stepIndex: number, selectorIndex: number, patch: Partial<ApprovalSelector>) {
const step = steps[stepIndex];
patchStep(stepIndex, {
selectors: step.selectors.map((selector, index) => index === selectorIndex ? { ...selector, ...patch } : selector)
});
}
return <>
<div className="approval-editor-heading">
<h3>Steps</h3>
<Button disabled={disabled} disabledReason={disabled ? APPROVALS_I18N.busy : undefined} onClick={() => onChange([...steps, emptyApprovalStep(steps.length + 1)])}><Plus size={16} aria-hidden="true" />Add step</Button>
</div>
<div className="approval-step-list">
{steps.map((step, stepIndex) => <section className="approval-step-editor" key={`${stepIndex}:${step.key}`}>
<div className="approval-step-heading">
<strong>{step.label || `Step ${stepIndex + 1}`}</strong>
<div>
<IconButton label={`Move ${step.label || "step"} up`} icon={<ArrowUp />} disabled={disabled || stepIndex === 0} onClick={() => moveStep(stepIndex, -1)} />
<IconButton label={`Move ${step.label || "step"} down`} icon={<ArrowDown />} disabled={disabled || stepIndex === steps.length - 1} onClick={() => moveStep(stepIndex, 1)} />
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 />} variant="danger" disabled={disabled || steps.length === 1} disabledReason={steps.length === 1 ? APPROVALS_I18N.oneStep : undefined} onClick={() => onChange(steps.filter((_, index) => index !== stepIndex))} />
</div>
</div>
<div className="approval-step-fields">
<FormField label="Key"><input value={step.key} disabled={disabled} onChange={(event) => patchStep(stepIndex, { key: event.target.value })} /></FormField>
<FormField label="Label"><input value={step.label} disabled={disabled} onChange={(event) => patchStep(stepIndex, { label: event.target.value })} /></FormField>
<FormField label="Required approvals"><input type="number" min={1} max={500} value={step.required_approvals} disabled={disabled} onChange={(event) => patchStep(stepIndex, { required_approvals: Number(event.target.value) })} /></FormField>
<FormField label="Rejection policy"><select value={step.rejection_policy} disabled={disabled} onChange={(event) => patchStep(stepIndex, { rejection_policy: event.target.value as ApprovalStep["rejection_policy"] })}><option value="fail_fast">Fail immediately</option><option value="collect">Collect all decisions</option></select></FormField>
<FormField label="Escalation due"><DateTimeField value={step.due_at ?? ""} disabled={disabled} onChange={(value) => patchStep(stepIndex, { due_at: value || null })} /></FormField>
<FormField label="Forbidden evidence roles"><input value={step.forbidden_evidence_roles.join(", ")} disabled={disabled} onChange={(event) => patchStep(stepIndex, { forbidden_evidence_roles: commaList(event.target.value) })} /></FormField>
<ToggleSwitch label="Signature required" checked={step.signature_required} disabled={disabled} onChange={(value) => patchStep(stepIndex, { signature_required: value })} />
</div>
<div className="approval-selector-heading"><span>Eligible actors</span><Button disabled={disabled} onClick={() => patchStep(stepIndex, { selectors: [...step.selectors, emptySelector()] })}><Plus size={16} aria-hidden="true" />Add actor selector</Button></div>
<div className="approval-selector-list">
{step.selectors.map((selector, selectorIndex) => <div key={selectorIndex}>
<FormField label="Actor type"><select value={selector.kind} disabled={disabled} onChange={(event) => { const kind = event.target.value as ApprovalSelector["kind"]; patchSelector(stepIndex, selectorIndex, { kind, value: kind === "any_account" ? "*" : selector.value === "*" ? "" : selector.value }); }}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function_assignment">Function assignment</option><option value="any_account">Any account</option></select></FormField>
<FormField label="Actor value"><input value={selector.value} disabled={disabled || selector.kind === "any_account"} onChange={(event) => patchSelector(stepIndex, selectorIndex, { value: event.target.value })} /></FormField>
<FormField label="Display label"><input value={selector.label ?? ""} disabled={disabled} onChange={(event) => patchSelector(stepIndex, selectorIndex, { label: event.target.value || null })} /></FormField>
<IconButton label="Remove actor selector" icon={<Trash2 />} variant="danger" disabled={disabled || step.selectors.length === 1} onClick={() => patchStep(stepIndex, { selectors: step.selectors.filter((_, index) => index !== selectorIndex) })} />
</div>)}
</div>
</section>)}
</div>
</>;
}
export function emptyApprovalStep(index: number): ApprovalStep {
return {
key: `step-${index}`,
label: "",
selectors: [emptySelector()],
required_approvals: 1,
rejection_policy: "fail_fast",
due_at: null,
signature_required: false,
forbidden_evidence_roles: [],
metadata: {}
};
}
function emptySelector(): ApprovalSelector {
return { kind: "account", value: "", label: null };
}
function commaList(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}
@@ -0,0 +1,242 @@
import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
AdminIconButton,
AdminPageLayout,
Button,
ConfirmDialog,
DataGrid,
Dialog,
DocumentationHelpLink,
FormField,
StatusBadge,
TableActionGroup,
ToggleSwitch,
adminErrorMessage,
formatAdminDateTime as formatDateTime,
type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui";
import {
approvalTemplateHistory,
compareApprovalTemplateRevisions,
createApprovalTemplate,
listApprovalTemplates,
publishApprovalTemplate,
reviseApprovalTemplate,
type ApprovalTemplate,
type ApprovalTemplateChange,
type ApprovalTemplateComparison,
type ApprovalTemplateDraft
} from "../../api/approvals";
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
import { APPROVALS_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
type EditorState = { mode: "create" | "edit"; current?: ApprovalTemplate };
export default function ApprovalTemplatesPanel({
settings,
canAdmin
}: {
settings: ApiSettings;
canAdmin: boolean;
}) {
const [templates, setTemplates] = useState<ApprovalTemplate[]>([]);
const [editor, setEditor] = useState<EditorState | null>(null);
const [draft, setDraft] = useState<ApprovalTemplateDraft>(emptyTemplate());
const [publishing, setPublishing] = useState<ApprovalTemplate | null>(null);
const [historyTemplate, setHistoryTemplate] = useState<ApprovalTemplate | null>(null);
const [history, setHistory] = useState<ApprovalTemplate[]>([]);
const [fromRevision, setFromRevision] = useState(1);
const [toRevision, setToRevision] = useState(1);
const [comparison, setComparison] = useState<ApprovalTemplateComparison | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
setTemplates(await listApprovalTemplates(settings));
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [settings.accessToken, settings.apiBaseUrl]);
const columns = useMemo<DataGridColumn<ApprovalTemplate>[]>(() => [
{ id: "title", header: "Template", width: "minmax(220px, 1fr)", minWidth: 190, fill: true, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => row.title, render: (row) => <div><strong>{row.title}</strong><div className="muted small-note">{row.key}</div></div> },
{ id: "state", header: "State", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={row.state} /> },
{ id: "revision", header: "Revision", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.revision },
{ id: "steps", header: "Steps", width: 90, resizable: false, sortable: true, value: (row) => row.steps.length },
{ id: "recorded", header: "Updated", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.recorded_at, render: (row) => formatDateTime(row.recorded_at) },
{ id: "actions", header: "Actions", width: 132, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
{ id: "edit", label: `Revise ${row.title}`, icon: <Pencil />, disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => openEdit(row) },
{ id: "history", label: `History for ${row.title}`, icon: <History />, onClick: () => void openHistory(row) },
{ id: "publish", label: `Publish ${row.title}`, icon: <Send />, applicable: row.state === "draft", disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => setPublishing(row) }
]} /> }
], [canAdmin]);
const historyColumns = useMemo<DataGridColumn<ApprovalTemplate>[]>(() => [
{ id: "revision", header: "Revision", width: 90, resizable: false, value: (row) => row.revision },
{ id: "state", header: "State", width: 110, resizable: false, value: (row) => row.state, render: (row) => <StatusBadge status={row.state} /> },
{ id: "recorded", header: "Recorded", width: 180, resizable: true, fill: true, value: (row) => row.recorded_at, render: (row) => formatDateTime(row.recorded_at) },
{ id: "actor", header: "Actor", width: "minmax(160px, 1fr)", minWidth: 140, resizable: true, value: (row) => row.actor_id || "", render: (row) => row.actor_id || "System" },
{ id: "hash", header: "Content hash", width: 180, resizable: true, value: (row) => row.content_sha256, render: (row) => <code title={row.content_sha256}>{row.content_sha256.slice(0, 16)}...</code> }
], []);
const changeColumns = useMemo<DataGridColumn<ApprovalTemplateChange>[]>(() => [
{ id: "path", header: "Path", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sticky: "start", value: (row) => row.path, render: (row) => <code>{row.path}</code> },
{ id: "change", header: "Change", width: 110, resizable: false, value: (row) => row.change, render: (row) => <StatusBadge status={row.change} /> },
{ id: "before", header: "Before", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.before), render: (row) => <code className="approval-diff-value">{printable(row.before)}</code> },
{ id: "after", header: "After", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.after), render: (row) => <code className="approval-diff-value">{printable(row.after)}</code> }
], []);
function openCreate() {
setDraft(emptyTemplate());
setEditor({ mode: "create" });
}
function openEdit(current: ApprovalTemplate) {
setDraft({
key: current.key,
title: current.title,
description: current.description ?? "",
steps: structuredClone(current.steps),
separation_of_duties: current.separation_of_duties,
unique_actors_across_steps: current.unique_actors_across_steps,
metadata: { ...current.metadata }
});
setEditor({ mode: "edit", current });
}
async function save() {
if (!editor) return;
setBusy(true);
setError("");
try {
const saved = editor.mode === "create"
? await createApprovalTemplate(settings, draft)
: await reviseApprovalTemplate(settings, editor.current!, draft);
setEditor(null);
setSuccess(`${saved.title} saved as draft revision ${saved.revision}.`);
await load();
} catch (reason) {
setError(adminErrorMessage(reason));
await load();
} finally {
setBusy(false);
}
}
async function publish() {
if (!publishing) return;
setBusy(true);
setError("");
try {
const published = await publishApprovalTemplate(settings, publishing);
setPublishing(null);
setSuccess(`${published.title} revision ${published.revision} published.`);
await load();
} catch (reason) {
setError(adminErrorMessage(reason));
await load();
} finally {
setBusy(false);
}
}
async function openHistory(template: ApprovalTemplate) {
setHistoryTemplate(template);
setComparison(null);
setError("");
try {
const revisions = await approvalTemplateHistory(settings, template.id);
setHistory(revisions);
const newest = revisions[0]?.revision ?? template.revision;
const oldest = revisions.at(-1)?.revision ?? newest;
setFromRevision(oldest);
setToRevision(newest);
} catch (reason) {
setError(adminErrorMessage(reason));
}
}
async function compare() {
if (!historyTemplate) return;
setBusy(true);
setError("");
try {
setComparison(await compareApprovalTemplateRevisions(settings, historyTemplate.id, fromRevision, toRevision));
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setBusy(false);
}
}
const valid = Boolean(
draft.key.trim()
&& draft.title.trim()
&& draft.steps.length
&& draft.steps.every((step) => step.key.trim() && step.label.trim() && step.required_approvals > 0 && step.selectors.length && step.selectors.every((selector) => selector.value.trim()))
);
return <>
<AdminPageLayout title="Approval templates" description="Define reusable, immutable approval chains and compare every published or draft revision." loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Create approval template" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : undefined} /></>}>
<div className="admin-table-surface"><DataGrid id="approval-templates-v1" rows={templates} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No approval templates found." /></div>
</AdminPageLayout>
<Dialog open={Boolean(editor)} title={editor?.mode === "create" ? "Create approval template" : "Revise approval template"} onClose={() => !busy && setEditor(null)} closeDisabled={busy} className="approval-template-dialog" footer={<><Button onClick={() => setEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !valid || !canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : !valid ? APPROVALS_I18N.incomplete : busy ? APPROVALS_I18N.busy : undefined}>{busy ? "Saving..." : "Save draft revision"}</Button></>}>
<div className="approval-editor">
<div className="approval-editor-grid">
<FormField label="Stable key"><input value={draft.key} disabled={busy || editor?.mode === "edit"} onChange={(event) => setDraft({ ...draft, key: event.target.value })} /></FormField>
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</div>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div>
</Dialog>
<Dialog open={Boolean(historyTemplate)} title={`${historyTemplate?.title ?? "Template"} history`} onClose={() => !busy && setHistoryTemplate(null)} className="approval-template-history-dialog" footer={<Button onClick={() => setHistoryTemplate(null)} disabled={busy}>Close</Button>}>
<div className="approval-template-history-layout">
<div className="admin-table-surface"><DataGrid id="approval-template-history-v1" rows={history} columns={historyColumns} initialFit="container" getRowKey={(row) => `${row.id}:${row.revision}`} emptyText="No template revisions found." /></div>
<div className="approval-compare-toolbar">
<FormField label="From revision"><select value={fromRevision} onChange={(event) => setFromRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
<FormField label="To revision"><select value={toRevision} onChange={(event) => setToRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
<Button onClick={() => void compare()} disabled={busy || !history.length}><GitCompareArrows aria-hidden="true" />Compare</Button>
</div>
{comparison && <div className="admin-table-surface"><DataGrid id="approval-template-compare-v1" rows={comparison.changes} columns={changeColumns} initialFit="container" getRowKey={(row) => `${row.path}:${row.change}`} emptyText="These revisions have identical template content." /></div>}
</div>
</Dialog>
<ConfirmDialog open={Boolean(publishing)} title="Publish approval template" message={`Publish ${publishing?.title ?? "this template"}? Requests can then bind permanently to the new immutable revision.`} confirmLabel="Publish revision" busy={busy} onCancel={() => setPublishing(null)} onConfirm={() => void publish()} />
</>;
}
function emptyTemplate(): ApprovalTemplateDraft {
return {
key: "",
title: "",
description: "",
steps: [emptyApprovalStep(1)],
separation_of_duties: true,
unique_actors_across_steps: false,
metadata: {}
};
}
function printable(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
@@ -0,0 +1,95 @@
import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui";
import { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
import ApprovalRequestDialog from "./ApprovalRequestDialog";
import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
export default function ApprovalsPage({ settings, auth }: PlatformRouteContext) {
const { language } = usePlatformLanguage();
const [items, setItems] = useState<ApprovalRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<ApprovalRequest | null>(null);
const [history, setHistory] = useState<ApprovalEvent[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [decision, setDecision] = useState<"approved" | "rejected" | null>(null);
const [escalating, setEscalating] = useState(false);
const canWrite = hasScope(auth, "approvals:workspace:write");
const canDecide = hasScope(auth, "approvals:workspace:decide");
const canAdmin = hasScope(auth, "approvals:workspace:admin");
const currentStep = selected?.steps[selected.current_step_index];
const escalationDue = Boolean(currentStep?.due_at && new Date(currentStep.due_at).getTime() <= Date.now());
const load = useCallback(async (signal?: AbortSignal, preferred?: string) => {
setLoading(true);
try {
const response = await listApprovals(settings, signal);
setItems(response.requests);
setSelectedId((current) => preferred ?? current ?? response.requests[0]?.id ?? null);
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval requests could not be loaded.")); });
return () => controller.abort();
}, [load]);
useEffect(() => {
if (!selectedId) { setSelected(null); setHistory([]); return; }
const controller = new AbortController();
Promise.all([getApproval(settings, selectedId, controller.signal), approvalHistory(settings, selectedId, controller.signal)]).then(([item, events]) => { setSelected(item); setHistory(events); }).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval details could not be loaded.")); });
return () => controller.abort();
}, [selectedId, settings]);
async function reload(id: string) {
const [item, events] = await Promise.all([getApproval(settings, id), approvalHistory(settings, id)]);
setSelected(item);
setHistory(events);
await load(undefined, id);
}
return <main className="approvals-page"><div className="approvals-shell">
<aside className="approvals-list-panel">
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? APPROVALS_I18N.loading : busy ? APPROVALS_I18N.busy : undefined} onClick={() => void load()} /><Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /></div>
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div>{!loading && items.length === 0 && <div className="approvals-empty">No approval requests</div>}</PageScrollViewport>
</aside>
<section className="approvals-workspace">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />}
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canAdmin && selected.state === "pending" && <Button disabled={busy || !escalationDue} disabledReason={busy ? APPROVALS_I18N.busy : !escalationDue ? "The current step is not due for escalation." : undefined} onClick={() => setEscalating(true)}><AlarmClock size={16} aria-hidden="true" />Escalate</Button>}<Button variant="primary" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></div></header>
<div className="approval-metrics"><Metric label="Revision" value={selected.revision} /><Metric label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><Metric label="Requested by" value={selected.requested_by || "-"} /><Metric label="Steps" value={selected.steps.length} /></div>
{selected.description && <p>{selected.description}</p>}
<section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section>
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section>
</div></PageScrollViewport>}
{!selected && !loading && <div className="approvals-empty">Select or create an approval request.</div>}
</section>
</div>
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); return true; } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); return false; } finally { setBusy(false); } }} />}
{selected && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => setEscalating(false)} onConfirm={() => { setBusy(true); setError(""); void escalateApproval(settings, selected).then(() => { setEscalating(false); return reload(selected.id); }).catch((failure) => setError(text(failure, "The Approval step could not be escalated."))).finally(() => setBusy(false)); }} />}
</main>;
}
function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise<boolean> }) {
const [reason, setReason] = useState("");
const [signatureId, setSignatureId] = useState("");
const { requestDiscard } = useUnsavedChanges();
const dirty = Boolean(reason || signatureId);
const valid = Boolean(reason.trim() && (!signatureRequired || signatureId.trim()));
useUnsavedDraftGuard({ dirty, onSave: async () => valid && onConfirm(reason.trim(), signatureId.trim()), onDiscard: () => { setReason(""); setSignatureId(""); }, title: "i18n:govoplan-approvals.unsaved_title", message: "i18n:govoplan-approvals.unsaved_message" });
const requestClose = () => { if (busy) return; if (dirty) requestDiscard(onClose); else onClose(); };
return <Dialog open title={`${humanize(outcome)} request`} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div><FormField label="Reason" documentation={APPROVALS_FIELD_DOCUMENTATION}><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
}
function Metric({ label, value }: { label: string; value: string | number }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; }
function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; }
@@ -0,0 +1,27 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const APPROVALS_DOCUMENTATION = {
topicId: "approvals.module-boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const APPROVALS_FIELD_DOCUMENTATION = {
topicId: "approvals.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const APPROVALS_I18N = {
loading: "i18n:govoplan-approvals.loading_reason",
busy: "i18n:govoplan-approvals.busy_reason",
writeReason: "i18n:govoplan-approvals.write_permission_reason",
decideReason: "i18n:govoplan-approvals.decide_permission_reason",
lifecycleReason: "i18n:govoplan-approvals.lifecycle_reason",
incomplete: "i18n:govoplan-approvals.incomplete_reason",
oneStep: "i18n:govoplan-approvals.one_step_reason",
requiredAction: "i18n:govoplan-approvals.required_action",
actor: "i18n:govoplan-approvals.responsible_actor",
destination: "i18n:govoplan-approvals.destination",
permissionAction: "i18n:govoplan-approvals.permission_action",
permissionActor: "i18n:govoplan-approvals.permission_actor",
permissionDestination: "i18n:govoplan-approvals.permission_destination"
} as const;
+129
View File
@@ -0,0 +1,129 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-approvals.approvals": "Approvals",
"i18n:govoplan-approvals.loading_reason": "Approval data is still loading.",
"i18n:govoplan-approvals.busy_reason": "Another Approval action is still running.",
"i18n:govoplan-approvals.write_permission_reason": "Your account may not create Approval requests.",
"i18n:govoplan-approvals.decide_permission_reason": "Your account may not decide this Approval request.",
"i18n:govoplan-approvals.lifecycle_reason": "Only pending or escalated requests can be decided.",
"i18n:govoplan-approvals.incomplete_reason": "Complete the required subject, digest, steps, and selector fields first.",
"i18n:govoplan-approvals.one_step_reason": "An Approval chain must retain at least one step.",
"i18n:govoplan-approvals.required_action": "Required action",
"i18n:govoplan-approvals.responsible_actor": "Responsible actor",
"i18n:govoplan-approvals.destination": "Destination",
"i18n:govoplan-approvals.permission_action": "Ask for the corresponding Approval permission.",
"i18n:govoplan-approvals.permission_actor": "An Access or tenant administrator",
"i18n:govoplan-approvals.permission_destination": "Access role assignments",
"i18n:govoplan-approvals.unsaved_title": "Unsaved Approval request",
"i18n:govoplan-approvals.unsaved_message": "Save or discard the Approval draft before leaving this surface.",
"Approvals": "Approvals",
"Refresh approvals": "Refresh approvals",
"New request": "New request",
"Loading approvals": "Loading approvals",
"No approval requests": "No approval requests",
"Select or create an approval request.": "Select or create an approval request.",
"No Approval creation permission": "No Approval creation permission",
"Approve": "Approve",
"Reject": "Reject",
"Revision": "Revision",
"Current step": "Current step",
"Requested by": "Requested by",
"Steps": "Steps",
"Complete": "Complete",
"Approval chain": "Approval chain",
"History": "History",
"Signature": "Signature",
"Cancel": "Cancel",
"Confirm": "Confirm",
"Reason": "Reason",
"Signature reference": "Signature reference",
"New Approval request": "New Approval request",
"Creating": "Creating",
"Create request": "Create request",
"Title": "Title",
"Subject module": "Subject module",
"Subject type": "Subject type",
"Subject ID": "Subject ID",
"Subject version": "Subject version",
"Subject SHA-256": "Subject SHA-256",
"Description": "Description",
"Separate requester and approver": "Separate requester and approver",
"Different actor for every step": "Different actor for every step",
"Add step": "Add step",
"Key": "Key",
"Label": "Label",
"Actor type": "Actor type",
"Actor value": "Actor value",
"Required": "Required",
"Account": "Account",
"Group": "Group",
"Role": "Role",
"Function assignment": "Function assignment",
"Any account": "Any account"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-approvals.approvals": "Genehmigungen",
"i18n:govoplan-approvals.loading_reason": "Genehmigungsdaten werden noch geladen.",
"i18n:govoplan-approvals.busy_reason": "Eine andere Genehmigungsaktion läuft noch.",
"i18n:govoplan-approvals.write_permission_reason": "Ihr Konto darf keine Genehmigungsanträge erstellen.",
"i18n:govoplan-approvals.decide_permission_reason": "Ihr Konto darf diesen Genehmigungsantrag nicht entscheiden.",
"i18n:govoplan-approvals.lifecycle_reason": "Nur ausstehende oder eskalierte Anträge können entschieden werden.",
"i18n:govoplan-approvals.incomplete_reason": "Füllen Sie zuerst Gegenstand, Prüfsumme, Schritte und Auswahlfelder aus.",
"i18n:govoplan-approvals.one_step_reason": "Eine Genehmigungskette muss mindestens einen Schritt behalten.",
"i18n:govoplan-approvals.required_action": "Erforderliche Aktion",
"i18n:govoplan-approvals.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-approvals.destination": "Ziel",
"i18n:govoplan-approvals.permission_action": "Fordern Sie die entsprechende Genehmigungsberechtigung an.",
"i18n:govoplan-approvals.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
"i18n:govoplan-approvals.permission_destination": "Zugriff und Rollenzuweisungen",
"i18n:govoplan-approvals.unsaved_title": "Ungespeicherter Genehmigungsantrag",
"i18n:govoplan-approvals.unsaved_message": "Speichern oder verwerfen Sie den Genehmigungsentwurf, bevor Sie diese Oberfläche verlassen.",
"Approvals": "Genehmigungen",
"Refresh approvals": "Genehmigungen aktualisieren",
"New request": "Neuer Antrag",
"Loading approvals": "Genehmigungen werden geladen",
"No approval requests": "Keine Genehmigungsanträge",
"Select or create an approval request.": "Wählen oder erstellen Sie einen Genehmigungsantrag.",
"No Approval creation permission": "Keine Berechtigung zum Erstellen von Genehmigungen",
"Approve": "Genehmigen",
"Reject": "Ablehnen",
"Revision": "Revision",
"Current step": "Aktueller Schritt",
"Requested by": "Beantragt von",
"Steps": "Schritte",
"Complete": "Abgeschlossen",
"Approval chain": "Genehmigungskette",
"History": "Verlauf",
"Signature": "Signatur",
"Cancel": "Abbrechen",
"Confirm": "Bestätigen",
"Reason": "Grund",
"Signature reference": "Signaturreferenz",
"New Approval request": "Neuer Genehmigungsantrag",
"Creating": "Erstellen",
"Create request": "Antrag erstellen",
"Title": "Titel",
"Subject module": "Gegenstandsmodul",
"Subject type": "Gegenstandsart",
"Subject ID": "Gegenstands-ID",
"Subject version": "Gegenstandsversion",
"Subject SHA-256": "SHA-256 des Gegenstands",
"Description": "Beschreibung",
"Separate requester and approver": "Antragstellende und Genehmigende trennen",
"Different actor for every step": "Unterschiedliche Stelle für jeden Schritt",
"Add step": "Schritt hinzufügen",
"Key": "Schlüssel",
"Label": "Bezeichnung",
"Actor type": "Akteursart",
"Actor value": "Akteurswert",
"Required": "Erforderlich",
"Account": "Konto",
"Group": "Gruppe",
"Role": "Rolle",
"Function assignment": "Funktionszuordnung",
"Any account": "Beliebiges Konto"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, approvalsModule } from "./module";
export * from "./api/approvals";
+47
View File
@@ -0,0 +1,47 @@
import { createElement, lazy } from "react";
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/approvals.css";
const ApprovalsPage = lazy(() => import("./features/approvals/ApprovalsPage"));
const ApprovalTemplatesPanel = lazy(() => import("./features/approvals/ApprovalTemplatesPanel"));
const approvalsAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-approval-templates",
moduleId: "approvals",
kind: "management",
surfaceId: "approvals.admin.templates",
label: "Approval templates",
group: "TENANT",
order: 55,
anyOf: ["approvals:workspace:read", "approvals:workspace:admin"],
render: ({ settings, auth }) => createElement(ApprovalTemplatesPanel, {
settings,
canAdmin: hasScope(auth, "approvals:workspace:admin")
})
}
]
};
export const approvalsModule: PlatformWebModule = {
id: "approvals",
label: "i18n:govoplan-approvals.approvals",
version: "0.1.14",
dependencies: ["access"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
translations: generatedTranslations,
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
viewSurfaces: [
{ id: "approvals.navigation", moduleId: "approvals", kind: "navigation", label: "Approvals navigation", order: 10 },
{ id: "approvals.workspace", moduleId: "approvals", kind: "route", label: "Approval request workspace", order: 20 },
{ id: "approvals.admin.templates", moduleId: "approvals", kind: "section", label: "Approval templates", order: 30 }
],
uiCapabilities: {
"admin.sections": approvalsAdminSections
}
};
export default approvalsModule;
+48
View File
@@ -0,0 +1,48 @@
.approvals-page { height: 100%; min-height: 0; overflow: hidden; }
.approvals-shell { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); height: 100%; min-height: 0; }
.approvals-list-panel { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--border-color, #d8dde3); }
.approvals-toolbar { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #d8dde3); }
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
.approvals-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; }
.approvals-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 52px; padding: 7px 8px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.approvals-list button:hover, .approvals-list button.is-selected { background: var(--hover-bg, rgba(54, 99, 135, 0.1)); }
.approvals-list button > span { display: flex; min-width: 0; flex-direction: column; }
.approvals-list small, .approvals-detail header span { color: var(--text-muted, #65717e); }
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.approvals-workspace > .action-blocker-hint { margin: 12px 16px 0; }
.approvals-empty { display: grid; min-height: 120px; place-items: center; padding: 16px; color: var(--text-muted, #65717e); text-align: center; }
.approvals-detail { padding: 16px 20px 28px; }
.approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color, #d8dde3); }
.approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; }
.approvals-detail h2, .approvals-detail h3, .approval-editor-heading h3 { margin: 0; font-size: 1rem; letter-spacing: 0; }
.approval-metrics { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 10px; margin: 16px 0; }
.approval-metrics > div { display: flex; flex-direction: column; padding: 10px 12px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
.approval-metrics span { color: var(--text-muted, #65717e); font-size: .75rem; text-transform: uppercase; }
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-color, #d8dde3); }
.approval-chain, .approval-history { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.approval-chain > div, .approval-history > div { display: grid; grid-template-columns: 32px minmax(120px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 7px 9px; background: var(--surface-muted, rgba(127,137,147,.08)); }
.approval-chain > div.is-current { border-left: 3px solid var(--accent-color, #36709a); }
.approval-chain em { font-size: .8rem; font-style: normal; }
.approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; }
.approval-history time { color: var(--text-muted, #65717e); font-size: .82rem; }
.approval-request-dialog, .approval-template-dialog { width: min(1160px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); }
.approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
.approval-editor-help { display: flex; justify-content: flex-end; }
.approval-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.approval-editor-wide { grid-column: 1 / -1; }
.approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-list { display: flex; flex-direction: column; gap: 8px; }
.approval-step-editor { display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
.approval-step-heading, .approval-selector-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-heading > div { display: flex; align-items: center; gap: 4px; }
.approval-step-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 8px; }
.approval-selector-heading { padding-top: 8px; border-top: 1px solid var(--border-color, #d8dde3); }
.approval-selector-list { display: flex; flex-direction: column; gap: 6px; }
.approval-selector-list > div { display: grid; grid-template-columns: 180px minmax(160px, 1fr) minmax(160px, 1fr) 34px; align-items: end; gap: 8px; }
.approval-template-history-dialog { width: min(1180px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
.approval-template-history-layout { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 12px; overflow: hidden; }
.approval-template-history-layout > .admin-table-surface { min-height: 180px; flex: 1; overflow: auto; }
.approval-compare-toolbar { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 8px; }
.approval-diff-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; }
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const panel = readFileSync("src/features/approvals/ApprovalTemplatesPanel.tsx", "utf8");
const approvalsPage = readFileSync("src/features/approvals/ApprovalsPage.tsx", "utf8");
const api = readFileSync("src/api/approvals.ts", "utf8");
const moduleSource = readFileSync("src/module.ts", "utf8");
assert.match(panel, /Approval templates/);
assert.match(panel, /approvalTemplateHistory/);
assert.match(panel, /compareApprovalTemplateRevisions/);
assert.match(panel, /publishApprovalTemplate/);
assert.match(panel, /<ConfirmDialog[\s\S]*Publish approval template/);
assert.match(approvalsPage, /escalateApproval/);
assert.match(approvalsPage, /!escalationDue/);
assert.match(api, /templates\/\$\{encodeURIComponent\(templateId\)\}\/history/);
assert.match(api, /templates\/\$\{encodeURIComponent\(templateId\)\}\/compare/);
assert.match(moduleSource, /approvals\.admin\.templates/);
assert.doesNotMatch(`${panel}\n${approvalsPage}`, /window\.(?:alert|confirm|prompt)\(/);
console.log("Approval template administration UI structural contract passed.");