Files
govoplan-risk-compliance/src/govoplan_risk_compliance/backend/assurance.py
T

1141 lines
36 KiB
Python

from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
from datetime import UTC, datetime
import hashlib
import json
import re
from typing import Any, Mapping
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskScreeningRun,
)
from govoplan_risk_compliance.backend.permissions import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
ASSURANCE_NODE_KINDS = (
"obligation",
"governed_object",
"risk",
"control",
"evidence",
"finding",
"corrective_measure",
"effectiveness_review",
)
ASSURANCE_EDGE_RELATIONS: Mapping[str, tuple[str, str]] = {
"applies_to": ("obligation", "governed_object"),
"exposes_risk": ("governed_object", "risk"),
"mitigated_by": ("risk", "control"),
"evidenced_by": ("control", "evidence"),
"results_in": ("evidence", "finding"),
"addressed_by": ("finding", "corrective_measure"),
"reviewed_by": ("corrective_measure", "effectiveness_review"),
}
ASSURANCE_EDGE_STATES = frozenset({"active", "suspended", "retired"})
ASSURANCE_NODE_STATES: Mapping[str, frozenset[str]] = {
"obligation": frozenset({"active", "suspended", "retired"}),
"governed_object": frozenset({"active", "inactive", "retired"}),
"risk": frozenset({"identified", "assessed", "accepted", "mitigated", "closed"}),
"control": frozenset({"designed", "implemented", "effective", "failed", "suspended", "retired"}),
"evidence": frozenset({"current", "stale", "invalid", "superseded"}),
"finding": frozenset({"open", "accepted", "exception", "remediating", "resolved"}),
"corrective_measure": frozenset({"planned", "in_progress", "completed", "cancelled"}),
"effectiveness_review": frozenset({"pending", "effective", "ineffective", "inconclusive"}),
}
MAX_GRAPH_DEPTH = 8
MAX_GRAPH_ITEMS = 500
MAX_PROVENANCE_BYTES = 100_000
_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,254}$")
class RiskAssuranceError(ValueError):
pass
class RiskAssuranceAccessError(RiskAssuranceError):
pass
class RiskAssuranceNotFoundError(RiskAssuranceError):
pass
class RiskAssuranceConflictError(RiskAssuranceError):
pass
@dataclass(frozen=True, slots=True)
class AssuranceNodeInput:
stable_id: str
kind: str
label: str
state: str
owner_ref: str
valid_from: datetime
description: str | None = None
scope_ref: str | None = None
governed_object_ref: str | None = None
valid_to: datetime | None = None
provenance: Mapping[str, Any] = field(default_factory=dict)
legal_basis_refs: tuple[str, ...] = ()
policy_refs: tuple[str, ...] = ()
evidence_refs: tuple[str, ...] = ()
classification: str = "internal"
def __post_init__(self) -> None:
_validate_node_input(self)
@dataclass(frozen=True, slots=True)
class AssuranceEdgeInput:
stable_id: str
source_node_ref: str
target_node_ref: str
relation: str
owner_ref: str
valid_from: datetime
state: str = "active"
scope_ref: str | None = None
valid_to: datetime | None = None
provenance: Mapping[str, Any] = field(default_factory=dict)
legal_basis_refs: tuple[str, ...] = ()
policy_refs: tuple[str, ...] = ()
evidence_refs: tuple[str, ...] = ()
def __post_init__(self) -> None:
_validate_edge_input(self)
@dataclass(frozen=True, slots=True)
class AssuranceGraph:
root_ref: str
nodes: tuple[RiskAssuranceNode, ...]
edges: tuple[RiskAssuranceEdge, ...]
truncated: bool
def create_assurance_node(
session: Session,
principal: ApiPrincipal,
*,
value: AssuranceNodeInput,
) -> RiskAssuranceNode:
_require_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
if _current_node(session, principal.tenant_id, value.stable_id) is not None:
raise RiskAssuranceConflictError(
f"Assurance node {value.stable_id!r} already exists."
)
return _insert_node(
session,
tenant_id=principal.tenant_id,
value=value,
revision=1,
previous=None,
actor_id=_actor_id(principal),
)
def revise_assurance_node(
session: Session,
principal: ApiPrincipal,
*,
stable_id: str,
expected_revision: int,
value: AssuranceNodeInput,
) -> RiskAssuranceNode:
_require_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
if value.stable_id != stable_id:
raise RiskAssuranceConflictError(
"The assurance node identity cannot change during revision."
)
current = _locked_current_node(session, principal.tenant_id, stable_id)
if current is None:
raise RiskAssuranceNotFoundError("Assurance node not found.")
if current.revision != expected_revision:
raise RiskAssuranceConflictError(
f"Expected revision {expected_revision}, current revision is {current.revision}."
)
if current.kind != value.kind:
raise RiskAssuranceConflictError(
"The assurance node kind cannot change during revision."
)
return _insert_node(
session,
tenant_id=principal.tenant_id,
value=value,
revision=current.revision + 1,
previous=current,
actor_id=_actor_id(principal),
)
def create_assurance_edge(
session: Session,
principal: ApiPrincipal,
*,
value: AssuranceEdgeInput,
) -> RiskAssuranceEdge:
_require_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
if _current_edge(session, principal.tenant_id, value.stable_id) is not None:
raise RiskAssuranceConflictError(
f"Assurance edge {value.stable_id!r} already exists."
)
_validate_edge_endpoints(session, principal.tenant_id, value)
return _insert_edge(
session,
tenant_id=principal.tenant_id,
value=value,
revision=1,
previous=None,
actor_id=_actor_id(principal),
)
def revise_assurance_edge(
session: Session,
principal: ApiPrincipal,
*,
stable_id: str,
expected_revision: int,
value: AssuranceEdgeInput,
) -> RiskAssuranceEdge:
_require_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
if value.stable_id != stable_id:
raise RiskAssuranceConflictError(
"The assurance edge identity cannot change during revision."
)
current = _locked_current_edge(session, principal.tenant_id, stable_id)
if current is None:
raise RiskAssuranceNotFoundError("Assurance edge not found.")
if current.revision != expected_revision:
raise RiskAssuranceConflictError(
f"Expected revision {expected_revision}, current revision is {current.revision}."
)
identity = (
value.source_node_ref,
value.target_node_ref,
value.relation,
)
if identity != (
current.source_node_ref,
current.target_node_ref,
current.relation,
):
raise RiskAssuranceConflictError(
"Edge endpoints and relation cannot change during revision."
)
_validate_edge_endpoints(session, principal.tenant_id, value)
return _insert_edge(
session,
tenant_id=principal.tenant_id,
value=value,
revision=current.revision + 1,
previous=current,
actor_id=_actor_id(principal),
)
def get_assurance_node(
session: Session,
principal: ApiPrincipal,
*,
stable_id: str,
) -> RiskAssuranceNode:
_require_scope(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
item = _current_node(session, principal.tenant_id, stable_id)
if item is None:
raise RiskAssuranceNotFoundError("Assurance node not found.")
return item
def list_assurance_node_history(
session: Session,
principal: ApiPrincipal,
*,
stable_id: str,
limit: int = 100,
) -> tuple[RiskAssuranceNode, ...]:
_require_scope(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
bounded_limit = _bounded_limit(limit)
return tuple(
session.scalars(
select(RiskAssuranceNode)
.where(
RiskAssuranceNode.tenant_id == principal.tenant_id,
RiskAssuranceNode.stable_id == stable_id,
)
.order_by(RiskAssuranceNode.revision.desc())
.limit(bounded_limit)
)
)
def list_assurance_edge_history(
session: Session,
principal: ApiPrincipal,
*,
stable_id: str,
limit: int = 100,
) -> tuple[RiskAssuranceEdge, ...]:
_require_scope(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
bounded_limit = _bounded_limit(limit)
return tuple(
session.scalars(
select(RiskAssuranceEdge)
.where(
RiskAssuranceEdge.tenant_id == principal.tenant_id,
RiskAssuranceEdge.stable_id == stable_id,
)
.order_by(RiskAssuranceEdge.revision.desc())
.limit(bounded_limit)
)
)
def list_assurance_nodes(
session: Session,
principal: ApiPrincipal,
*,
kind: str | None = None,
state: str | None = None,
governed_object_ref: str | None = None,
query: str | None = None,
limit: int = 100,
) -> tuple[RiskAssuranceNode, ...]:
_require_scope(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
statement = select(RiskAssuranceNode).where(
RiskAssuranceNode.tenant_id == principal.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
if kind:
_validate_kind(kind)
statement = statement.where(RiskAssuranceNode.kind == kind)
if state:
statement = statement.where(RiskAssuranceNode.state == state)
if governed_object_ref:
statement = statement.where(
RiskAssuranceNode.governed_object_ref == governed_object_ref
)
search = str(query or "").strip()
if search:
pattern = f"%{_escape_like(search)}%"
statement = statement.where(
or_(
RiskAssuranceNode.label.ilike(pattern, escape="\\"),
RiskAssuranceNode.description.ilike(pattern, escape="\\"),
RiskAssuranceNode.stable_id.ilike(pattern, escape="\\"),
)
)
return tuple(
session.scalars(
statement.order_by(
RiskAssuranceNode.kind,
RiskAssuranceNode.label,
RiskAssuranceNode.stable_id,
).limit(_bounded_limit(limit))
)
)
def assurance_graph(
session: Session,
principal: ApiPrincipal,
*,
root_ref: str,
max_depth: int = 4,
limit: int = 200,
) -> AssuranceGraph:
_require_scope(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
if not 0 <= max_depth <= MAX_GRAPH_DEPTH:
raise RiskAssuranceConflictError(
f"Graph depth must be between 0 and {MAX_GRAPH_DEPTH}."
)
bounded_limit = _bounded_limit(limit)
root = _current_node(session, principal.tenant_id, root_ref)
if root is None:
raise RiskAssuranceNotFoundError("Assurance graph root not found.")
nodes: dict[str, RiskAssuranceNode] = {root.stable_id: root}
edges: dict[str, RiskAssuranceEdge] = {}
queue: deque[tuple[str, int]] = deque(((root.stable_id, 0),))
expanded: set[str] = set()
truncated = False
while queue:
node_ref, depth = queue.popleft()
if node_ref in expanded or depth >= max_depth:
continue
expanded.add(node_ref)
remaining = bounded_limit - len(edges)
if remaining <= 0:
truncated = True
break
connected = tuple(
session.scalars(
select(RiskAssuranceEdge)
.where(
RiskAssuranceEdge.tenant_id == principal.tenant_id,
RiskAssuranceEdge.superseded_at.is_(None),
RiskAssuranceEdge.state == "active",
or_(
RiskAssuranceEdge.source_node_ref == node_ref,
RiskAssuranceEdge.target_node_ref == node_ref,
),
)
.order_by(RiskAssuranceEdge.stable_id)
.limit(remaining + 1)
)
)
if len(connected) > remaining:
connected = connected[:remaining]
truncated = True
neighbor_refs: set[str] = set()
for edge in connected:
edges[edge.stable_id] = edge
neighbor_refs.update(
(edge.source_node_ref, edge.target_node_ref)
)
missing = neighbor_refs - nodes.keys()
if missing:
for item in session.scalars(
select(RiskAssuranceNode).where(
RiskAssuranceNode.tenant_id == principal.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
RiskAssuranceNode.stable_id.in_(missing),
)
):
nodes[item.stable_id] = item
for neighbor in sorted(neighbor_refs):
if neighbor not in expanded:
queue.append((neighbor, depth + 1))
if len(nodes) >= bounded_limit:
truncated = truncated or bool(queue)
break
selected_nodes = tuple(
sorted(nodes.values(), key=lambda item: (item.kind, item.stable_id))
)[:bounded_limit]
selected_refs = {item.stable_id for item in selected_nodes}
selected_edges = tuple(
edge
for edge in sorted(edges.values(), key=lambda item: item.stable_id)
if edge.source_node_ref in selected_refs
and edge.target_node_ref in selected_refs
)[:bounded_limit]
return AssuranceGraph(
root_ref=root_ref,
nodes=selected_nodes,
edges=selected_edges,
truncated=truncated,
)
def assurance_summary(
session: Session,
principal: ApiPrincipal,
) -> dict[str, object]:
_require_scope(principal, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
rows = session.execute(
select(
RiskAssuranceNode.kind,
RiskAssuranceNode.state,
func.count(RiskAssuranceNode.id),
)
.where(
RiskAssuranceNode.tenant_id == principal.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
.group_by(RiskAssuranceNode.kind, RiskAssuranceNode.state)
)
by_kind: Counter[str] = Counter()
by_state: Counter[str] = Counter()
for kind, state, count in rows:
by_kind[str(kind)] += int(count)
by_state[str(state)] += int(count)
return {
"node_count": sum(by_kind.values()),
"edge_count": int(
session.scalar(
select(func.count(RiskAssuranceEdge.id)).where(
RiskAssuranceEdge.tenant_id == principal.tenant_id,
RiskAssuranceEdge.superseded_at.is_(None),
)
)
or 0
),
"by_kind": dict(sorted(by_kind.items())),
"by_state": dict(sorted(by_state.items())),
}
def record_sanctions_assurance(
session: Session,
principal: ApiPrincipal,
*,
run: RiskScreeningRun,
) -> tuple[RiskAssuranceNode, ...]:
"""Project a completed sanctions run into the horizontal assurance graph."""
if run.tenant_id != principal.tenant_id:
raise RiskAssuranceAccessError("Screening assurance cannot cross tenants.")
if run.status != "completed":
raise RiskAssuranceConflictError(
"Only completed screenings can become assurance evidence."
)
source = run.list_snapshot
subject = run.subject_snapshot
now = _as_aware(run.completed_at or utcnow())
started_at = _as_aware(run.started_at)
source_effective_at = _as_aware(
source.effective_at or source.acquired_at
)
actor_id = _actor_id(principal)
legal_ref = (
f"sanctions-list:{source.jurisdiction}:{source.source_id}:"
f"{source.source_version}"
)
evidence_ref = f"risk-compliance:sanctions-screening:{run.id}"
owner_ref = "risk-compliance:sanctions"
inputs = (
AssuranceNodeInput(
stable_id=_bounded_id(
f"sanctions-obligation:{source.jurisdiction}:{source.list_type}"
),
kind="obligation",
label=f"{source.jurisdiction} sanctions screening obligation",
state="active",
owner_ref=owner_ref,
valid_from=source_effective_at,
provenance={
"provider_id": source.provider_id,
"source_version": source.source_version,
},
legal_basis_refs=(legal_ref,),
),
AssuranceNodeInput(
stable_id=f"sanctions-subject:{subject.fingerprint}",
kind="governed_object",
label="Screened party",
state="active",
owner_ref=owner_ref,
valid_from=started_at,
governed_object_ref=(
subject.subject_ref
or f"risk-compliance:screening-subject:{subject.id}"
),
provenance={"subject_fingerprint": subject.fingerprint},
classification="confidential",
),
AssuranceNodeInput(
stable_id=f"sanctions-risk:{subject.fingerprint}",
kind="risk",
label="Sanctions exposure",
state=("identified" if run.outcome == "potential" else "mitigated"),
owner_ref=owner_ref,
valid_from=started_at,
provenance={"screening_outcome": run.outcome},
legal_basis_refs=(legal_ref,),
classification="confidential",
),
AssuranceNodeInput(
stable_id=_bounded_id(f"sanctions-control:{source.provider_id}"),
kind="control",
label="Version-pinned sanctions screening",
state=(
"failed"
if run.outcome in {"unavailable", "stale", "insufficient"}
else "effective"
),
owner_ref=owner_ref,
valid_from=started_at,
provenance={
"matcher_version": run.matcher_version,
"normalization_version": run.normalization_version,
"policy_version": run.policy_version,
},
policy_refs=(f"sanctions-policy:{run.policy_version}",),
),
AssuranceNodeInput(
stable_id=f"sanctions-evidence:{run.id}",
kind="evidence",
label="Sanctions screening evidence",
state=(
"stale"
if run.outcome == "stale"
else "invalid"
if run.outcome in {"unavailable", "insufficient"}
else "current"
),
owner_ref=owner_ref,
valid_from=now,
provenance={
"list_snapshot_id": run.list_snapshot_id,
"list_source_version": source.source_version,
"request_hash": run.request_hash,
},
evidence_refs=(evidence_ref,),
classification="confidential",
),
AssuranceNodeInput(
stable_id=f"sanctions-finding:{run.id}",
kind="finding",
label=(
"Potential sanctions match"
if run.outcome == "potential"
else "Sanctions screening result"
),
state="open" if run.outcome == "potential" else "resolved",
owner_ref=owner_ref,
valid_from=now,
provenance={
"outcome": run.outcome,
"candidate_count": run.candidate_count,
},
legal_basis_refs=(legal_ref,),
evidence_refs=(evidence_ref,),
classification="confidential",
),
)
nodes = tuple(
_ensure_node(
session,
tenant_id=principal.tenant_id,
value=value,
actor_id=actor_id,
)
for value in inputs
)
edge_inputs = (
(nodes[0], "applies_to", nodes[1]),
(nodes[1], "exposes_risk", nodes[2]),
(nodes[2], "mitigated_by", nodes[3]),
(nodes[3], "evidenced_by", nodes[4]),
(nodes[4], "results_in", nodes[5]),
)
for source_node, relation, target_node in edge_inputs:
_ensure_edge(
session,
tenant_id=principal.tenant_id,
value=AssuranceEdgeInput(
stable_id=_edge_id(
source_node.stable_id,
relation,
target_node.stable_id,
),
source_node_ref=source_node.stable_id,
target_node_ref=target_node.stable_id,
relation=relation,
owner_ref=owner_ref,
valid_from=now,
provenance={"screening_run_id": run.id},
evidence_refs=(evidence_ref,),
),
actor_id=actor_id,
)
session.flush()
return nodes
def _insert_node(
session: Session,
*,
tenant_id: str,
value: AssuranceNodeInput,
revision: int,
previous: RiskAssuranceNode | None,
actor_id: str | None,
) -> RiskAssuranceNode:
recorded_at = utcnow()
if previous is not None:
previous.superseded_at = recorded_at
item = RiskAssuranceNode(
tenant_id=tenant_id,
stable_id=value.stable_id,
kind=value.kind,
revision=revision,
previous_revision_id=previous.id if previous is not None else None,
label=value.label.strip(),
description=_optional_text(value.description),
state=value.state,
owner_ref=value.owner_ref.strip(),
scope_ref=_optional_text(value.scope_ref),
governed_object_ref=_optional_text(value.governed_object_ref),
valid_from=value.valid_from,
valid_to=value.valid_to,
recorded_at=recorded_at,
provenance=dict(value.provenance),
legal_basis_refs=list(value.legal_basis_refs),
policy_refs=list(value.policy_refs),
evidence_refs=list(value.evidence_refs),
classification=value.classification.strip(),
created_by=actor_id,
)
session.add(item)
session.flush()
return item
def _insert_edge(
session: Session,
*,
tenant_id: str,
value: AssuranceEdgeInput,
revision: int,
previous: RiskAssuranceEdge | None,
actor_id: str | None,
) -> RiskAssuranceEdge:
recorded_at = utcnow()
if previous is not None:
previous.superseded_at = recorded_at
item = RiskAssuranceEdge(
tenant_id=tenant_id,
stable_id=value.stable_id,
revision=revision,
previous_revision_id=previous.id if previous is not None else None,
source_node_ref=value.source_node_ref,
target_node_ref=value.target_node_ref,
relation=value.relation,
state=value.state,
owner_ref=value.owner_ref.strip(),
scope_ref=_optional_text(value.scope_ref),
valid_from=value.valid_from,
valid_to=value.valid_to,
recorded_at=recorded_at,
provenance=dict(value.provenance),
legal_basis_refs=list(value.legal_basis_refs),
policy_refs=list(value.policy_refs),
evidence_refs=list(value.evidence_refs),
created_by=actor_id,
)
session.add(item)
session.flush()
return item
def _ensure_node(
session: Session,
*,
tenant_id: str,
value: AssuranceNodeInput,
actor_id: str | None,
) -> RiskAssuranceNode:
current = _locked_current_node(session, tenant_id, value.stable_id)
if current is None:
return _insert_node(
session,
tenant_id=tenant_id,
value=value,
revision=1,
previous=None,
actor_id=actor_id,
)
if _node_matches(current, value):
return current
if current.kind != value.kind:
raise RiskAssuranceConflictError(
f"Assurance node {value.stable_id!r} changed kind."
)
return _insert_node(
session,
tenant_id=tenant_id,
value=value,
revision=current.revision + 1,
previous=current,
actor_id=actor_id,
)
def _ensure_edge(
session: Session,
*,
tenant_id: str,
value: AssuranceEdgeInput,
actor_id: str | None,
) -> RiskAssuranceEdge:
current = _locked_current_edge(session, tenant_id, value.stable_id)
if current is None:
_validate_edge_endpoints(session, tenant_id, value)
return _insert_edge(
session,
tenant_id=tenant_id,
value=value,
revision=1,
previous=None,
actor_id=actor_id,
)
if _edge_matches(current, value):
return current
identity = (
value.source_node_ref,
value.target_node_ref,
value.relation,
)
if identity != (
current.source_node_ref,
current.target_node_ref,
current.relation,
):
raise RiskAssuranceConflictError(
f"Assurance edge {value.stable_id!r} changed identity."
)
return _insert_edge(
session,
tenant_id=tenant_id,
value=value,
revision=current.revision + 1,
previous=current,
actor_id=actor_id,
)
def _current_node(
session: Session,
tenant_id: str,
stable_id: str,
) -> RiskAssuranceNode | None:
return session.scalar(
select(RiskAssuranceNode).where(
RiskAssuranceNode.tenant_id == tenant_id,
RiskAssuranceNode.stable_id == stable_id,
RiskAssuranceNode.superseded_at.is_(None),
)
)
def _locked_current_node(
session: Session,
tenant_id: str,
stable_id: str,
) -> RiskAssuranceNode | None:
return session.scalar(
select(RiskAssuranceNode)
.where(
RiskAssuranceNode.tenant_id == tenant_id,
RiskAssuranceNode.stable_id == stable_id,
RiskAssuranceNode.superseded_at.is_(None),
)
.with_for_update()
)
def _current_edge(
session: Session,
tenant_id: str,
stable_id: str,
) -> RiskAssuranceEdge | None:
return session.scalar(
select(RiskAssuranceEdge).where(
RiskAssuranceEdge.tenant_id == tenant_id,
RiskAssuranceEdge.stable_id == stable_id,
RiskAssuranceEdge.superseded_at.is_(None),
)
)
def _locked_current_edge(
session: Session,
tenant_id: str,
stable_id: str,
) -> RiskAssuranceEdge | None:
return session.scalar(
select(RiskAssuranceEdge)
.where(
RiskAssuranceEdge.tenant_id == tenant_id,
RiskAssuranceEdge.stable_id == stable_id,
RiskAssuranceEdge.superseded_at.is_(None),
)
.with_for_update()
)
def _validate_edge_endpoints(
session: Session,
tenant_id: str,
value: AssuranceEdgeInput,
) -> None:
source = _current_node(session, tenant_id, value.source_node_ref)
target = _current_node(session, tenant_id, value.target_node_ref)
if source is None or target is None:
raise RiskAssuranceNotFoundError(
"Both assurance edge endpoints must exist in the current tenant."
)
expected = ASSURANCE_EDGE_RELATIONS[value.relation]
if (source.kind, target.kind) != expected:
raise RiskAssuranceConflictError(
f"Relation {value.relation!r} requires {expected[0]} -> {expected[1]}."
)
def _validate_node_input(value: AssuranceNodeInput) -> None:
_validate_id(value.stable_id, "Assurance node stable id")
_validate_kind(value.kind)
_require_text(value.label, "Assurance node label", 500)
_require_text(value.owner_ref, "Assurance node owner", 500)
_validate_optional_text(value.description, "Assurance node description", 20_000)
_validate_optional_text(value.scope_ref, "Assurance node scope", 500)
_validate_optional_text(
value.governed_object_ref,
"Assurance governed object reference",
1_000,
)
if value.state not in ASSURANCE_NODE_STATES[value.kind]:
raise RiskAssuranceError(
f"Unsupported {value.kind} state: {value.state!r}."
)
if value.kind == "governed_object" and not _optional_text(
value.governed_object_ref
):
raise RiskAssuranceError(
"Governed-object nodes require an opaque governed object reference."
)
_validate_period(value.valid_from, value.valid_to)
_validate_reference_values(value.legal_basis_refs, "legal basis")
_validate_reference_values(value.policy_refs, "policy")
_validate_reference_values(value.evidence_refs, "evidence")
_validate_provenance(value.provenance)
_require_text(value.classification, "Assurance classification", 50)
def _validate_edge_input(value: AssuranceEdgeInput) -> None:
_validate_id(value.stable_id, "Assurance edge stable id")
_validate_id(value.source_node_ref, "Assurance edge source")
_validate_id(value.target_node_ref, "Assurance edge target")
if value.source_node_ref == value.target_node_ref:
raise RiskAssuranceError("Assurance edges cannot reference themselves.")
if value.relation not in ASSURANCE_EDGE_RELATIONS:
raise RiskAssuranceError(
f"Unsupported assurance relation: {value.relation!r}."
)
if value.state not in ASSURANCE_EDGE_STATES:
raise RiskAssuranceError(
f"Unsupported assurance edge state: {value.state!r}."
)
_require_text(value.owner_ref, "Assurance edge owner", 500)
_validate_optional_text(value.scope_ref, "Assurance edge scope", 500)
_validate_period(value.valid_from, value.valid_to)
_validate_reference_values(value.legal_basis_refs, "legal basis")
_validate_reference_values(value.policy_refs, "policy")
_validate_reference_values(value.evidence_refs, "evidence")
_validate_provenance(value.provenance)
def _validate_kind(value: str) -> None:
if value not in ASSURANCE_NODE_STATES:
raise RiskAssuranceError(f"Unsupported assurance node kind: {value!r}.")
def _validate_period(valid_from: datetime, valid_to: datetime | None) -> None:
for value in (valid_from, valid_to):
if value is not None and (
value.tzinfo is None or value.utcoffset() is None
):
raise RiskAssuranceError(
"Assurance effective times must include a timezone."
)
if valid_to is not None and valid_to <= valid_from:
raise RiskAssuranceError(
"Assurance valid_to must be later than valid_from."
)
def _validate_reference_values(values: tuple[str, ...], label: str) -> None:
if len(values) > 100:
raise RiskAssuranceError(
f"Assurance {label} references are limited to 100 values."
)
if len(values) != len(set(values)):
raise RiskAssuranceError(
f"Assurance {label} references cannot contain duplicates."
)
for value in values:
_require_text(value, f"Assurance {label} reference", 1000)
def _node_matches(item: RiskAssuranceNode, value: AssuranceNodeInput) -> bool:
return (
item.kind,
item.label,
item.description,
item.state,
item.owner_ref,
item.scope_ref,
item.governed_object_ref,
_time_key(item.valid_from),
_time_key(item.valid_to),
dict(item.provenance),
tuple(item.legal_basis_refs),
tuple(item.policy_refs),
tuple(item.evidence_refs),
item.classification,
) == (
value.kind,
value.label.strip(),
_optional_text(value.description),
value.state,
value.owner_ref.strip(),
_optional_text(value.scope_ref),
_optional_text(value.governed_object_ref),
_time_key(value.valid_from),
_time_key(value.valid_to),
dict(value.provenance),
value.legal_basis_refs,
value.policy_refs,
value.evidence_refs,
value.classification.strip(),
)
def _edge_matches(item: RiskAssuranceEdge, value: AssuranceEdgeInput) -> bool:
return (
item.source_node_ref,
item.target_node_ref,
item.relation,
item.state,
item.owner_ref,
item.scope_ref,
_time_key(item.valid_from),
_time_key(item.valid_to),
dict(item.provenance),
tuple(item.legal_basis_refs),
tuple(item.policy_refs),
tuple(item.evidence_refs),
) == (
value.source_node_ref,
value.target_node_ref,
value.relation,
value.state,
value.owner_ref.strip(),
_optional_text(value.scope_ref),
_time_key(value.valid_from),
_time_key(value.valid_to),
dict(value.provenance),
value.legal_basis_refs,
value.policy_refs,
value.evidence_refs,
)
def _edge_id(source_ref: str, relation: str, target_ref: str) -> str:
digest = hashlib.sha256(
f"{source_ref}\0{relation}\0{target_ref}".encode("utf-8")
).hexdigest()[:32]
return f"assurance-edge:{digest}"
def _as_aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def _time_key(value: datetime | None) -> datetime | None:
return _as_aware(value).astimezone(UTC) if value is not None else None
def _bounded_id(value: str) -> str:
if len(value) <= 255 and _ID_PATTERN.fullmatch(value):
return value
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
prefix = re.sub(r"[^A-Za-z0-9_.:/-]", "-", value[:200]).rstrip("-")
return f"{prefix}:{digest}"[:255]
def _validate_id(value: str, label: str) -> None:
if not _ID_PATTERN.fullmatch(str(value or "")):
raise RiskAssuranceError(f"{label} is invalid.")
def _require_text(value: str, label: str, limit: int) -> None:
cleaned = str(value or "").strip()
if not cleaned:
raise RiskAssuranceError(f"{label} is required.")
if len(cleaned) > limit:
raise RiskAssuranceError(f"{label} is limited to {limit} characters.")
def _optional_text(value: str | None) -> str | None:
cleaned = str(value or "").strip()
return cleaned or None
def _validate_optional_text(value: str | None, label: str, limit: int) -> None:
cleaned = _optional_text(value)
if cleaned is not None and len(cleaned) > limit:
raise RiskAssuranceError(f"{label} is limited to {limit} characters.")
def _validate_provenance(value: Mapping[str, Any]) -> None:
try:
encoded = json.dumps(
dict(value),
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise RiskAssuranceError(
"Assurance provenance must contain JSON-compatible values."
) from exc
if len(encoded) > MAX_PROVENANCE_BYTES:
raise RiskAssuranceError(
f"Assurance provenance is limited to {MAX_PROVENANCE_BYTES} bytes."
)
def _bounded_limit(value: int) -> int:
if not 1 <= value <= MAX_GRAPH_ITEMS:
raise RiskAssuranceConflictError(
f"Assurance result limits must be between 1 and {MAX_GRAPH_ITEMS}."
)
return value
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _require_scope(principal: ApiPrincipal, *scopes: str) -> None:
if not any(principal.has(scope) for scope in scopes):
raise RiskAssuranceAccessError("Risk assurance access is not permitted.")
def _actor_id(principal: ApiPrincipal) -> str | None:
return getattr(principal.user, "id", None) or getattr(
principal.account,
"id",
None,
)
__all__ = [
"ASSURANCE_EDGE_RELATIONS",
"ASSURANCE_NODE_KINDS",
"AssuranceEdgeInput",
"AssuranceGraph",
"AssuranceNodeInput",
"RiskAssuranceAccessError",
"RiskAssuranceConflictError",
"RiskAssuranceError",
"RiskAssuranceNotFoundError",
"assurance_graph",
"assurance_summary",
"create_assurance_edge",
"create_assurance_node",
"get_assurance_node",
"list_assurance_edge_history",
"list_assurance_node_history",
"list_assurance_nodes",
"record_sanctions_assurance",
"revise_assurance_edge",
"revise_assurance_node",
]