Implement generic approval runtime

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 2b081cb931
commit 62d0f300cb
23 changed files with 3133 additions and 118 deletions
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalDecisionCommand,
ApprovalRequestCreateCommand,
ApprovalStepDefinition,
ApprovalTemplateCreateCommand,
)
from govoplan_core.db.base import Base
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
DIGEST = "a" * 64
@dataclass
class Principal:
tenant_id: str
account_id: str
group_ids: tuple[str, ...] = ()
role_ids: tuple[str, ...] = ()
function_assignment_ids: tuple[str, ...] = ()
acting_for_account_id: str | None = None
acting_assignment_id: str | None = None
def request_command() -> ApprovalRequestCreateCommand:
return ApprovalRequestCreateCommand(
title="Approve Campaign delivery",
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-7",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
key="review",
label="Review",
selectors=(ApprovalActorSelector("group", "reviewers"),),
),
ApprovalStepDefinition(
key="release",
label="Release",
selectors=(ApprovalActorSelector("role", "senders"),),
signature_required=True,
),
),
separation_of_duties=True,
unique_actors_across_steps=True,
)
class ApprovalRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
self.service = SqlApprovalRequests()
self.requester = Principal("tenant-1", "requester")
def tearDown(self) -> None:
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_sequential_chain_exact_subject_and_signature(self) -> None:
with self.Session() as session:
created = self.service.create_request(
session,
self.requester,
command=request_command(),
idempotency_key="create-1",
)
review = self.service.decide(
session,
Principal("tenant-1", "reviewer", group_ids=("reviewers",)),
request_id=created.id,
command=ApprovalDecisionCommand("approved", "Reviewed.", 1, "review-1"),
)
self.assertEqual(2, review.revision)
current = self.service.get_request(
session, self.requester, request_id=created.id
)
self.assertEqual("release", current["current_step_key"])
with self.assertRaisesRegex(ApprovalStoreError, "signature"):
self.service.decide(
session,
Principal("tenant-1", "sender", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved", "Release.", 2, "release-no-signature"
),
)
released = self.service.decide(
session,
Principal("tenant-1", "sender", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved",
"Release.",
2,
"release-1",
signature_ref={"provider": "signatures", "id": "sig-1"},
),
)
self.assertEqual(3, released.revision)
check = self.service.check_approved(
session,
self.requester,
request_id=created.id,
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-7",
subject_digest=DIGEST,
)
self.assertTrue(check.approved)
with self.assertRaisesRegex(ApprovalStoreError, "exact requested subject"):
self.service.check_approved(
session,
self.requester,
request_id=created.id,
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-8",
subject_digest=DIGEST,
)
def test_separation_of_duties_rejection_replay_and_tenant_isolation(self) -> None:
direct = ApprovalRequestCreateCommand(
title="Direct review",
subject_module="campaigns",
subject_type="campaign_version",
subject_id="campaign-1",
subject_version="version-7",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
"review", "Review", (ApprovalActorSelector("account", "requester"),)
),
),
)
with self.Session() as session:
created = self.service.create_request(
session, self.requester, command=direct, idempotency_key="create-direct"
)
with self.assertRaisesRegex(ApprovalStoreError, "separation of duties"):
self.service.decide(
session,
self.requester,
request_id=created.id,
command=ApprovalDecisionCommand("approved", "Self.", 1, "self-1"),
)
self.assertIsNone(
self.service.get_request(
session, Principal("tenant-2", "requester"), request_id=created.id
)
)
rejected_command = ApprovalRequestCreateCommand(
title="Review",
subject_module="cases",
subject_type="case",
subject_id="case-1",
subject_version="1",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
"review",
"Review",
(ApprovalActorSelector("account", "reviewer"),),
),
),
)
rejected = self.service.create_request(
session,
self.requester,
command=rejected_command,
idempotency_key="create-reject",
)
receipt = self.service.decide(
session,
Principal("tenant-1", "reviewer"),
request_id=rejected.id,
command=ApprovalDecisionCommand(
"rejected", "Insufficient evidence.", 1, "reject-1"
),
)
replay = self.service.decide(
session,
Principal("tenant-1", "reviewer"),
request_id=rejected.id,
command=ApprovalDecisionCommand(
"rejected", "Insufficient evidence.", 1, "reject-1"
),
)
self.assertEqual(receipt.receipt_sha256, replay.receipt_sha256)
self.assertTrue(replay.replayed)
self.assertEqual(
"rejected",
self.service.get_request(
session, self.requester, request_id=rejected.id
)["state"],
)
def test_due_step_escalates_explicitly(self) -> None:
command = ApprovalRequestCreateCommand(
title="Due review",
subject_module="files",
subject_type="file",
subject_id="file-1",
subject_version="1",
subject_digest=DIGEST,
steps=(
ApprovalStepDefinition(
"review",
"Review",
(ApprovalActorSelector("account", "reviewer"),),
due_at=datetime.now(UTC) - timedelta(minutes=1),
),
),
)
with self.Session() as session:
created = self.service.create_request(
session, self.requester, command=command, idempotency_key="create-due"
)
escalated = self.service.escalate_due(
session,
self.requester,
request_id=created.id,
expected_revision=1,
idempotency_key="escalate-1",
)
self.assertEqual("escalated", escalated.state)
def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
template_command = ApprovalTemplateCreateCommand(
key="campaign-release",
title="Campaign release",
steps=(
ApprovalStepDefinition(
"release",
"Release",
(ApprovalActorSelector("role", "senders"),),
forbidden_evidence_roles=("builder",),
),
),
)
with self.Session() as session:
draft = self.service.create_template(
session,
self.requester,
command=template_command,
idempotency_key="template-1",
)
published = self.service.publish_template(
session,
self.requester,
template_id=draft.id,
expected_revision=1,
idempotency_key="publish-1",
)
request = ApprovalRequestCreateCommand(
title="Approve exact execution",
subject_module="campaigns",
subject_type="campaign_execution",
subject_id="campaign-1",
subject_version="build-7",
subject_digest=DIGEST,
steps=(),
evidence_actors={"builder": ("builder",)},
template_id=published.id,
template_revision=published.revision,
)
created = self.service.create_request(
session,
self.requester,
command=request,
idempotency_key="templated-request-1",
)
with self.assertRaisesRegex(ApprovalStoreError, "builder actor"):
self.service.decide(
session,
Principal("tenant-1", "builder", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved", "Built and release attempted.", 1, "builder-release"
),
)
receipt = self.service.decide(
session,
Principal("tenant-1", "sender", role_ids=("senders",)),
request_id=created.id,
command=ApprovalDecisionCommand(
"approved", "Independent release.", 1, "sender-release"
),
)
self.assertEqual(
"role", receipt.authority_provenance["matched_selector"]["kind"]
)
with self.assertRaisesRegex(ApprovalStoreError, "content digest"):
self.service.check_approved(
session,
self.requester,
request_id=created.id,
subject_module="campaigns",
subject_type="campaign_execution",
subject_id="campaign-1",
subject_version="build-7",
subject_digest="b" * 64,
)
if __name__ == "__main__":
unittest.main()