Files
govoplan-campaign/src/govoplan_campaign/backend/approval_gate.py
T

266 lines
8.5 KiB
Python

from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Mapping
from sqlalchemy.orm import Session
from govoplan_core.core.approvals import (
ApprovalRequestCreateCommand,
ApprovalRequestRef,
ApprovalStepDefinition,
)
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.integrations import (
ApprovalGateUnavailable,
approvals_integration,
)
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
APPROVAL_GATE_KEY = "approval_gate"
SUBJECT_MODULE = "campaigns"
SUBJECT_TYPE = "campaign_execution"
class CampaignApprovalGateError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class _TenantPrincipal:
tenant_id: str
account_id: str | None = None
def campaign_approval_gate(version: CampaignVersion) -> dict[str, object] | None:
state = version.editor_state if isinstance(version.editor_state, dict) else {}
gate = state.get(APPROVAL_GATE_KEY)
return dict(gate) if isinstance(gate, dict) else None
def request_campaign_approval(
session: Session,
principal: object,
*,
campaign: Campaign,
version: CampaignVersion,
title: str,
description: str | None,
steps: tuple[ApprovalStepDefinition, ...],
idempotency_key: str,
template_id: str | None = None,
template_revision: int | None = None,
unique_actors_across_steps: bool = True,
expires_at: datetime | None = None,
policy_refs: tuple[str, ...] = (),
) -> ApprovalRequestRef:
if campaign.tenant_id != str(getattr(principal, "tenant_id", "") or ""):
raise CampaignApprovalGateError("Campaign approval tenant mismatch.")
if version.campaign_id != campaign.id:
raise CampaignApprovalGateError("Campaign approval version mismatch.")
snapshot = ensure_execution_snapshot(session, version)
digest = str(version.execution_snapshot_hash or "")
if len(digest) != 64:
raise CampaignApprovalGateError(
"Build a valid Campaign execution snapshot before requesting approval."
)
subject_version = _subject_version(version, snapshot.build_token)
evidence_actors = _campaign_evidence_actors(campaign, version)
try:
request = approvals_integration().create_request(
session,
principal,
command=ApprovalRequestCreateCommand(
title=title,
description=description,
subject_module=SUBJECT_MODULE,
subject_type=SUBJECT_TYPE,
subject_id=version.id,
subject_version=subject_version,
subject_digest=digest,
steps=steps,
separation_of_duties=True,
unique_actors_across_steps=unique_actors_across_steps,
expires_at=expires_at,
policy_refs=policy_refs,
evidence_actors=evidence_actors,
template_id=template_id,
template_revision=template_revision,
metadata={
"campaign_id": campaign.id,
"campaign_version_number": version.version_number,
"execution_snapshot_version": snapshot.snapshot_version,
},
),
idempotency_key=idempotency_key,
)
except ApprovalGateUnavailable as exc:
raise CampaignApprovalGateError(str(exc)) from exc
state = copy.deepcopy(version.editor_state or {})
state[APPROVAL_GATE_KEY] = {
"request_id": request.id,
"request_revision": request.revision,
"subject_version": subject_version,
"subject_digest": digest,
"requested_at": datetime.now(UTC).isoformat(),
"requested_by_user_id": _actor_id(principal),
}
version.editor_state = state
session.add(version)
session.flush()
return request
def assert_campaign_approval(
session: Session,
*,
tenant_id: str,
version: CampaignVersion,
) -> None:
gate = campaign_approval_gate(version)
if gate is None:
return
snapshot = ensure_execution_snapshot(session, version)
digest = str(version.execution_snapshot_hash or "")
subject_version = _subject_version(version, snapshot.build_token)
if (
gate.get("subject_digest") != digest
or gate.get("subject_version") != subject_version
):
raise CampaignApprovalGateError(
"Campaign execution changed after approval was requested. Request approval for the current build."
)
request_id = str(gate.get("request_id") or "").strip()
if not request_id:
raise CampaignApprovalGateError(
"Campaign approval gate has no request reference."
)
try:
check = approvals_integration().check_approved(
session,
_TenantPrincipal(tenant_id=tenant_id),
request_id=request_id,
subject_module=SUBJECT_MODULE,
subject_type=SUBJECT_TYPE,
subject_id=version.id,
subject_version=subject_version,
subject_digest=digest,
)
except (ApprovalGateUnavailable, LookupError, ValueError) as exc:
raise CampaignApprovalGateError(str(exc)) from exc
if not check.approved:
raise CampaignApprovalGateError(
f"Campaign delivery requires Approval request {request_id}, which is {check.state}."
)
def campaign_approval_status(
session: Session,
*,
tenant_id: str,
version: CampaignVersion,
) -> dict[str, object]:
gate = campaign_approval_gate(version)
integration = approvals_integration()
if gate is None:
return {
"configured": False,
"available": integration.available,
"approved": False,
"state": "not_required",
"explanation": None,
}
try:
assert_campaign_approval(session, tenant_id=tenant_id, version=version)
except CampaignApprovalGateError as exc:
return {
**gate,
"configured": True,
"available": integration.available,
"approved": False,
"state": "unavailable" if not integration.available else "pending",
"explanation": str(exc),
}
return {
**gate,
"configured": True,
"available": True,
"approved": True,
"state": "approved",
"explanation": None,
}
def clear_campaign_approval_gate(version: CampaignVersion) -> None:
state = copy.deepcopy(version.editor_state or {})
state.pop(APPROVAL_GATE_KEY, None)
version.editor_state = state
def _campaign_evidence_actors(
campaign: Campaign,
version: CampaignVersion,
) -> Mapping[str, tuple[str, ...]]:
validation = (
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
build = version.build_summary if isinstance(version.build_summary, dict) else {}
editor = version.editor_state if isinstance(version.editor_state, dict) else {}
review = (
editor.get("review_send") if isinstance(editor.get("review_send"), dict) else {}
)
review_decisions = (
review.get("issue_decisions")
if isinstance(review.get("issue_decisions"), list)
else []
)
values = {
"author": (campaign.created_by_user_id,),
"owner": (campaign.owner_user_id,),
"validator": (
validation.get("validated_by_user_id"),
version.locked_by_user_id,
),
"builder": (build.get("built_by_user_id"),),
"reviewer": (
review.get("updated_by_user_id"),
*(
item.get("actor_user_id")
for item in review_decisions
if isinstance(item, dict)
),
),
}
return {
role: tuple(dict.fromkeys(str(item) for item in actors if item))
for role, actors in values.items()
if any(actors)
}
def _subject_version(version: CampaignVersion, build_token: str | None) -> str:
return str(build_token or f"campaign-version-{version.version_number}")[:120]
def _actor_id(principal: object) -> str | None:
for name in ("account_id", "user_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
return None
__all__ = [
"CampaignApprovalGateError",
"assert_campaign_approval",
"campaign_approval_gate",
"campaign_approval_status",
"clear_campaign_approval_gate",
"request_campaign_approval",
]