Add institutional provenance and approval gates
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user