feat: implement assurance graph and screening evidence

This commit is contained in:
2026-08-01 17:48:39 +02:00
parent bbf7288e14
commit a23b53dc9e
18 changed files with 4005 additions and 42 deletions
File diff suppressed because it is too large Load Diff
@@ -841,7 +841,274 @@ class RiskScreeningException(Base, TimestampMixin):
)
class RiskAssuranceNode(Base, TimestampMixin):
__tablename__ = "risk_assurance_nodes"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"stable_id",
"revision",
name="uq_risk_assurance_node_revision",
),
Index(
"ix_risk_assurance_node_current",
"tenant_id",
"kind",
"state",
"superseded_at",
),
Index(
"ix_risk_assurance_node_governed_object",
"tenant_id",
"governed_object_ref",
"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,
)
stable_id: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
)
kind: Mapped[str] = mapped_column(
String(40),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey(
"risk_assurance_nodes.id",
ondelete="RESTRICT",
),
nullable=True,
)
label: Mapped[str] = mapped_column(
String(500),
nullable=False,
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
state: Mapped[str] = mapped_column(
String(40),
nullable=False,
index=True,
)
owner_ref: Mapped[str] = mapped_column(
String(500),
nullable=False,
index=True,
)
scope_ref: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
index=True,
)
governed_object_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
index=True,
)
valid_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
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,
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
legal_basis_refs: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
policy_refs: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
evidence_refs: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
index=True,
)
created_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
class RiskAssuranceEdge(Base, TimestampMixin):
__tablename__ = "risk_assurance_edges"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"stable_id",
"revision",
name="uq_risk_assurance_edge_revision",
),
Index(
"ix_risk_assurance_edge_source",
"tenant_id",
"source_node_ref",
"state",
"superseded_at",
),
Index(
"ix_risk_assurance_edge_target",
"tenant_id",
"target_node_ref",
"state",
"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,
)
stable_id: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey(
"risk_assurance_edges.id",
ondelete="RESTRICT",
),
nullable=True,
)
source_node_ref: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
)
target_node_ref: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
)
relation: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
)
state: Mapped[str] = mapped_column(
String(30),
default="active",
nullable=False,
index=True,
)
owner_ref: Mapped[str] = mapped_column(
String(500),
nullable=False,
index=True,
)
scope_ref: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
index=True,
)
valid_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
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,
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
legal_basis_refs: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
policy_refs: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
evidence_refs: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
created_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
__all__ = [
"RiskAssuranceEdge",
"RiskAssuranceNode",
"RiskSanctionsAddress",
"RiskSanctionsAlias",
"RiskSanctionsDate",
+136 -22
View File
@@ -26,9 +26,17 @@ from govoplan_core.core.modules import (
from govoplan_core.core.sanctions import (
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskSanctionsAddress,
RiskSanctionsAlias,
RiskSanctionsDate,
@@ -66,6 +74,8 @@ OPTIONAL_DEPENDENCIES = (
"connectors",
)
_PERSISTENT_MODELS = (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskScreeningException,
RiskScreeningDisposition,
RiskScreeningCandidate,
@@ -79,6 +89,72 @@ _PERSISTENT_MODELS = (
RiskSanctionsListSnapshot,
)
ARCHITECTURE = ModuleArchitectureDeclaration(
layer="governance_accountability",
kind="governance",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_assurance_graph.py",
summary=(
"Exercises tenant-safe immutable graph revisions, bounded traversal, "
"sanctions projection, synthetic controls, ACL, and search."
),
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_sanctions_screening.py",
summary=(
"Exercises immutable sanctions evidence, deterministic matching, "
"review, exceptions, and freshness gates."
),
),
ModuleMaturityEvidence(
kind="migration",
reference="tests/test_migrations.py",
summary=(
"Exercises the persistent sanctions screening and assurance "
"graph schemas."
),
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",
summary="Defines assurance ownership and integration boundaries.",
),
),
known_limits=(
"The assurance graph provides generic governance primitives; domain modules still own corrective execution.",
"Cross-tenant aggregate assurance is intentionally not exposed by the tenant API.",
),
supported_authority_modes=(
"native_authoritative",
"external_mirror",
"governance_overlay",
"linked_reference",
),
owned_concepts=(
"risk and control evaluation",
"sanctions screening runs",
"candidate review and dispositions",
"compliance findings and assurance review",
),
non_owned_concepts=(
"external source transport and credentials",
"immutable audit event storage",
"policy rule evaluation",
"governed domain objects and corrective execution",
),
documentation=ModuleArchitectureDocumentation(
migration=("tests/test_migrations.py",),
upgrade=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
recovery=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
security=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
operations=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
),
)
def _permission(
scope: str,
@@ -140,9 +216,7 @@ ROLE_TEMPLATES = (
RoleTemplate(
slug="risk_compliance_manager",
name="Risk Compliance manager",
description=(
"Manage compliance workflows and administer sanctions screening."
),
description=("Manage compliance workflows and administer sanctions screening."),
permissions=(
READ_SCOPE,
WRITE_SCOPE,
@@ -155,9 +229,7 @@ ROLE_TEMPLATES = (
RoleTemplate(
slug="risk_compliance_reviewer",
name="Risk Compliance reviewer",
description=(
"Run screenings and independently review potential matches."
),
description=("Run screenings and independently review potential matches."),
permissions=(
READ_SCOPE,
SANCTIONS_READ_SCOPE,
@@ -188,13 +260,19 @@ def _sanctions_screening_provider(_context):
return RiskComplianceSanctionsScreeningProvider()
def _assurance_search_source(context):
from govoplan_risk_compliance.backend.search_source import (
create_risk_assurance_search_source,
)
return create_risk_assurance_search_source(context)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"risk_sanctions_list_snapshots": (
session.query(RiskSanctionsListSnapshot)
.filter(
RiskSanctionsListSnapshot.tenant_id == tenant_id
)
.filter(RiskSanctionsListSnapshot.tenant_id == tenant_id)
.count()
),
"risk_screening_runs": (
@@ -212,6 +290,14 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
)
.count()
),
"risk_assurance_nodes": (
session.query(RiskAssuranceNode)
.filter(
RiskAssuranceNode.tenant_id == tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
.count()
),
}
@@ -227,7 +313,11 @@ DOCUMENTATION = (
"Connectors may acquire source evidence, but Risk Compliance "
"owns immutable normalized sanctions lists, version-pinned "
"screening, candidate review, and legal dispositions. Fuzzy "
"matching only creates candidates and never confirms a match."
"matching only creates candidates and never confirms a match. "
"The broader module direction links obligations, governed object "
"references, risks, controls, evidence, findings, corrective "
"measures, and effectiveness reviews without copying the governed "
"domain object or replacing Policy and Audit."
),
layer="available",
documentation_types=("admin", "user"),
@@ -242,8 +332,7 @@ DOCUMENTATION = (
DocumentationLink(
label="Repository domain boundary",
href=(
"govoplan-risk-compliance/"
"docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md"
"govoplan-risk-compliance/docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md"
),
kind="repository",
),
@@ -260,6 +349,20 @@ DOCUMENTATION = (
"Queue and audit summaries contain stable references and "
"minimal subject data."
),
"assurance_domain_model": [
"obligation",
"governed object reference",
"risk",
"control",
"evidence",
"finding",
"corrective measure",
"effectiveness review",
],
"assurance_graph": (
"Every node and edge is effective-dated, revisioned, tenant-scoped, "
"and linked through opaque governed-object references."
),
},
),
)
@@ -292,10 +395,14 @@ manifest = ModuleManifest(
role_templates=ROLE_TEMPLATES,
route_factory=_route_factory,
capability_factories={
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (
_sanctions_screening_provider
),
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider),
},
search_sources=(
SearchSourceProviderRegistration(
id="risk_compliance.assurance",
factory=_assurance_search_source,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/risk-compliance-webui",
@@ -303,7 +410,7 @@ manifest = ModuleManifest(
FrontendRoute(
path="/risk-compliance",
component="RiskCompliancePage",
required_any=(SANCTIONS_READ_SCOPE,),
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
order=115,
surface_id="risk_compliance.workspace",
),
@@ -313,7 +420,7 @@ manifest = ModuleManifest(
path="/risk-compliance",
label="Risk Compliance",
icon="shield-check",
required_any=(SANCTIONS_READ_SCOPE,),
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
order=115,
surface_id="risk_compliance.navigation",
),
@@ -340,15 +447,21 @@ manifest = ModuleManifest(
label="Sanctions review queue",
order=40,
),
ViewSurface(
id="risk_compliance.assurance.graph",
module_id=MODULE_ID,
kind="section",
label="Assurance graph",
order=50,
),
),
),
tenant_summary_providers=(_tenant_summary,),
architecture=ARCHITECTURE,
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(
Path(__file__).with_name("migrations") / "versions"
),
script_location=str(Path(__file__).with_name("migrations") / "versions"),
migration_after=("connectors",),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
@@ -356,8 +469,9 @@ manifest = ModuleManifest(
label="Risk Compliance",
),
retirement_notes=(
"Destructive retirement removes immutable sanctions list, "
"screening, and review evidence after a database snapshot."
"Destructive retirement removes immutable assurance graph, "
"sanctions list, screening, and review evidence after a database "
"snapshot."
),
),
uninstall_guard_providers=(
@@ -0,0 +1,180 @@
"""Add the effective-dated horizontal assurance graph.
Revision ID: b9c0d1e2f3a4
Revises: a8b9c0d1e2f3
Create Date: 2026-08-01
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b9c0d1e2f3a4"
down_revision = "a8b9c0d1e2f3"
branch_labels = None
depends_on = None
def _timestamps() -> tuple[sa.Column, sa.Column]:
return (
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def upgrade() -> None:
op.create_table(
"risk_assurance_nodes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("stable_id", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=40), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("owner_ref", sa.String(length=500), nullable=False),
sa.Column("scope_ref", sa.String(length=500), nullable=True),
sa.Column("governed_object_ref", sa.String(length=1000), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("legal_basis_refs", sa.JSON(), nullable=False),
sa.Column("policy_refs", sa.JSON(), nullable=False),
sa.Column("evidence_refs", sa.JSON(), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["risk_assurance_nodes.id"],
name=op.f(
"fk_risk_assurance_nodes_previous_revision_id_"
"risk_assurance_nodes"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_risk_assurance_nodes")),
sa.UniqueConstraint(
"tenant_id",
"stable_id",
"revision",
name="uq_risk_assurance_node_revision",
),
)
_indexes(
"risk_assurance_nodes",
(
"tenant_id",
"stable_id",
"kind",
"state",
"owner_ref",
"scope_ref",
"governed_object_ref",
"valid_from",
"valid_to",
"recorded_at",
"superseded_at",
"classification",
"created_by",
),
)
op.create_index(
"ix_risk_assurance_node_current",
"risk_assurance_nodes",
["tenant_id", "kind", "state", "superseded_at"],
)
op.create_index(
"ix_risk_assurance_node_governed_object",
"risk_assurance_nodes",
["tenant_id", "governed_object_ref", "superseded_at"],
)
op.create_table(
"risk_assurance_edges",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("stable_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("source_node_ref", sa.String(length=255), nullable=False),
sa.Column("target_node_ref", sa.String(length=255), nullable=False),
sa.Column("relation", sa.String(length=50), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("owner_ref", sa.String(length=500), nullable=False),
sa.Column("scope_ref", sa.String(length=500), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("legal_basis_refs", sa.JSON(), nullable=False),
sa.Column("policy_refs", sa.JSON(), nullable=False),
sa.Column("evidence_refs", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["risk_assurance_edges.id"],
name=op.f(
"fk_risk_assurance_edges_previous_revision_id_"
"risk_assurance_edges"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_risk_assurance_edges")),
sa.UniqueConstraint(
"tenant_id",
"stable_id",
"revision",
name="uq_risk_assurance_edge_revision",
),
)
_indexes(
"risk_assurance_edges",
(
"tenant_id",
"stable_id",
"source_node_ref",
"target_node_ref",
"relation",
"state",
"owner_ref",
"scope_ref",
"valid_from",
"valid_to",
"recorded_at",
"superseded_at",
"created_by",
),
)
op.create_index(
"ix_risk_assurance_edge_source",
"risk_assurance_edges",
["tenant_id", "source_node_ref", "state", "superseded_at"],
)
op.create_index(
"ix_risk_assurance_edge_target",
"risk_assurance_edges",
["tenant_id", "target_node_ref", "state", "superseded_at"],
)
def downgrade() -> None:
op.drop_table("risk_assurance_edges")
op.drop_table("risk_assurance_nodes")
def _indexes(table_name: str, columns: tuple[str, ...]) -> None:
for column in columns:
op.create_index(
op.f(f"ix_{table_name}_{column}"),
table_name,
[column],
)
@@ -7,6 +7,23 @@ from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.sanctions import sanctions_snapshot_provider
from govoplan_core.db.session import get_session
from govoplan_risk_compliance.backend.assurance import (
AssuranceEdgeInput,
AssuranceNodeInput,
RiskAssuranceAccessError,
RiskAssuranceConflictError,
RiskAssuranceError,
RiskAssuranceNotFoundError,
assurance_graph,
assurance_summary,
create_assurance_edge,
create_assurance_node,
list_assurance_edge_history,
list_assurance_node_history,
list_assurance_nodes,
revise_assurance_edge,
revise_assurance_node,
)
from govoplan_risk_compliance.backend.review import (
DispositionInput,
get_candidate,
@@ -23,6 +40,16 @@ from govoplan_risk_compliance.backend.sanctions_catalog import (
list_list_snapshots,
)
from govoplan_risk_compliance.backend.schemas import (
AssuranceEdgeListResponse,
AssuranceEdgeResponse,
AssuranceEdgeRevisionRequest,
AssuranceEdgeWrite,
AssuranceGraphResponse,
AssuranceNodeListResponse,
AssuranceNodeResponse,
AssuranceNodeRevisionRequest,
AssuranceNodeWrite,
AssuranceSummaryResponse,
CandidateDetailResponse,
CandidateResponse,
ConnectorSnapshotListResponse,
@@ -92,6 +119,292 @@ def _http_error(exc: RiskSanctionsError) -> HTTPException:
)
def _assurance_http_error(exc: RiskAssuranceError) -> HTTPException:
if isinstance(exc, RiskAssuranceNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, RiskAssuranceAccessError):
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
if isinstance(exc, RiskAssuranceConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
@router.get(
"/assurance/summary",
response_model=AssuranceSummaryResponse,
)
def api_assurance_summary(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceSummaryResponse:
try:
payload = assurance_summary(session, principal)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
return AssuranceSummaryResponse.model_validate(payload)
@router.get(
"/assurance/nodes",
response_model=AssuranceNodeListResponse,
)
def api_list_assurance_nodes(
kind: str | None = Query(default=None, max_length=40),
state: str | None = Query(default=None, max_length=40),
governed_object_ref: str | None = Query(default=None, max_length=1000),
query: str | None = Query(default=None, max_length=500),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceNodeListResponse:
try:
items = list_assurance_nodes(
session,
principal,
kind=kind,
state=state,
governed_object_ref=governed_object_ref,
query=query,
limit=limit,
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
return AssuranceNodeListResponse(
nodes=[_assurance_node_response(item) for item in items]
)
@router.post(
"/assurance/nodes",
response_model=AssuranceNodeResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_assurance_node(
payload: AssuranceNodeWrite,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceNodeResponse:
try:
item = create_assurance_node(
session,
principal,
value=_assurance_node_input(payload),
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="risk_compliance.assurance_node.created",
scope="tenant",
object_type="risk_assurance_node",
object_id=item.stable_id,
details={
"kind": item.kind,
"state": item.state,
"revision": item.revision,
},
)
session.commit()
return _assurance_node_response(item)
@router.post(
"/assurance/nodes/revise",
response_model=AssuranceNodeResponse,
)
def api_revise_assurance_node(
payload: AssuranceNodeRevisionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceNodeResponse:
try:
item = revise_assurance_node(
session,
principal,
stable_id=payload.stable_id,
expected_revision=payload.expected_revision,
value=_assurance_node_input(payload),
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="risk_compliance.assurance_node.revised",
scope="tenant",
object_type="risk_assurance_node",
object_id=item.stable_id,
details={
"kind": item.kind,
"state": item.state,
"revision": item.revision,
},
)
session.commit()
return _assurance_node_response(item)
@router.get(
"/assurance/node-history",
response_model=AssuranceNodeListResponse,
)
def api_assurance_node_history(
stable_id: str = Query(min_length=1, max_length=255),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceNodeListResponse:
try:
items = list_assurance_node_history(
session,
principal,
stable_id=stable_id,
limit=limit,
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
return AssuranceNodeListResponse(
nodes=[_assurance_node_response(item) for item in items]
)
@router.post(
"/assurance/edges",
response_model=AssuranceEdgeResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_assurance_edge(
payload: AssuranceEdgeWrite,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceEdgeResponse:
try:
item = create_assurance_edge(
session,
principal,
value=_assurance_edge_input(payload),
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="risk_compliance.assurance_edge.created",
scope="tenant",
object_type="risk_assurance_edge",
object_id=item.stable_id,
details={
"relation": item.relation,
"state": item.state,
"revision": item.revision,
},
)
session.commit()
return _assurance_edge_response(item)
@router.post(
"/assurance/edges/revise",
response_model=AssuranceEdgeResponse,
)
def api_revise_assurance_edge(
payload: AssuranceEdgeRevisionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceEdgeResponse:
try:
item = revise_assurance_edge(
session,
principal,
stable_id=payload.stable_id,
expected_revision=payload.expected_revision,
value=_assurance_edge_input(payload),
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="risk_compliance.assurance_edge.revised",
scope="tenant",
object_type="risk_assurance_edge",
object_id=item.stable_id,
details={
"relation": item.relation,
"state": item.state,
"revision": item.revision,
},
)
session.commit()
return _assurance_edge_response(item)
@router.get(
"/assurance/edge-history",
response_model=AssuranceEdgeListResponse,
)
def api_assurance_edge_history(
stable_id: str = Query(min_length=1, max_length=255),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceEdgeListResponse:
try:
items = list_assurance_edge_history(
session,
principal,
stable_id=stable_id,
limit=limit,
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
return AssuranceEdgeListResponse(
edges=[_assurance_edge_response(item) for item in items]
)
@router.get(
"/assurance/graph",
response_model=AssuranceGraphResponse,
)
def api_assurance_graph(
root_ref: str = Query(min_length=1, max_length=255),
max_depth: int = Query(default=4, ge=0, le=8),
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceGraphResponse:
try:
graph = assurance_graph(
session,
principal,
root_ref=root_ref,
max_depth=max_depth,
limit=limit,
)
except RiskAssuranceError as exc:
raise _assurance_http_error(exc) from exc
return AssuranceGraphResponse(
root_ref=graph.root_ref,
nodes=[_assurance_node_response(item) for item in graph.nodes],
edges=[_assurance_edge_response(item) for item in graph.edges],
truncated=graph.truncated,
)
@router.get(
"/sanctions/source-snapshots",
response_model=ConnectorSnapshotListResponse,
@@ -689,4 +1002,56 @@ def _disposition_response(item) -> DispositionResponse:
)
def _assurance_node_input(item: AssuranceNodeWrite) -> AssuranceNodeInput:
return AssuranceNodeInput(
stable_id=item.stable_id,
kind=item.kind,
label=item.label,
description=item.description,
state=item.state,
owner_ref=item.owner_ref,
scope_ref=item.scope_ref,
governed_object_ref=item.governed_object_ref,
valid_from=item.valid_from,
valid_to=item.valid_to,
provenance=item.provenance,
legal_basis_refs=tuple(item.legal_basis_refs),
policy_refs=tuple(item.policy_refs),
evidence_refs=tuple(item.evidence_refs),
classification=item.classification,
)
def _assurance_edge_input(item: AssuranceEdgeWrite) -> AssuranceEdgeInput:
return AssuranceEdgeInput(
stable_id=item.stable_id,
source_node_ref=item.source_node_ref,
target_node_ref=item.target_node_ref,
relation=item.relation,
state=item.state,
owner_ref=item.owner_ref,
scope_ref=item.scope_ref,
valid_from=item.valid_from,
valid_to=item.valid_to,
provenance=item.provenance,
legal_basis_refs=tuple(item.legal_basis_refs),
policy_refs=tuple(item.policy_refs),
evidence_refs=tuple(item.evidence_refs),
)
def _assurance_node_response(item) -> AssuranceNodeResponse:
return AssuranceNodeResponse.model_validate(
item,
from_attributes=True,
)
def _assurance_edge_response(item) -> AssuranceEdgeResponse:
return AssuranceEdgeResponse.model_validate(
item,
from_attributes=True,
)
__all__ = ["router"]
@@ -352,7 +352,124 @@ class DispositionListResponse(BaseModel):
dispositions: list[DispositionResponse]
AssuranceNodeKind = Literal[
"obligation",
"governed_object",
"risk",
"control",
"evidence",
"finding",
"corrective_measure",
"effectiveness_review",
]
AssuranceEdgeRelation = Literal[
"applies_to",
"exposes_risk",
"mitigated_by",
"evidenced_by",
"results_in",
"addressed_by",
"reviewed_by",
]
class AssuranceNodeWrite(BaseModel):
stable_id: str = Field(min_length=1, max_length=255)
kind: AssuranceNodeKind
label: str = Field(min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=20_000)
state: str = Field(min_length=1, max_length=40)
owner_ref: str = Field(min_length=1, max_length=500)
scope_ref: str | None = Field(default=None, max_length=500)
governed_object_ref: str | None = Field(default=None, max_length=1000)
valid_from: datetime
valid_to: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
legal_basis_refs: list[str] = Field(default_factory=list, max_length=100)
policy_refs: list[str] = Field(default_factory=list, max_length=100)
evidence_refs: list[str] = Field(default_factory=list, max_length=100)
classification: str = Field(default="internal", min_length=1, max_length=50)
class AssuranceNodeRevisionRequest(AssuranceNodeWrite):
expected_revision: int = Field(ge=1)
class AssuranceNodeResponse(AssuranceNodeWrite):
id: str
revision: int
previous_revision_id: str | None
recorded_at: datetime
superseded_at: datetime | None
created_by: str | None
created_at: datetime
updated_at: datetime
class AssuranceNodeListResponse(BaseModel):
nodes: list[AssuranceNodeResponse]
class AssuranceEdgeWrite(BaseModel):
stable_id: str = Field(min_length=1, max_length=255)
source_node_ref: str = Field(min_length=1, max_length=255)
target_node_ref: str = Field(min_length=1, max_length=255)
relation: AssuranceEdgeRelation
state: Literal["active", "suspended", "retired"] = "active"
owner_ref: str = Field(min_length=1, max_length=500)
scope_ref: str | None = Field(default=None, max_length=500)
valid_from: datetime
valid_to: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
legal_basis_refs: list[str] = Field(default_factory=list, max_length=100)
policy_refs: list[str] = Field(default_factory=list, max_length=100)
evidence_refs: list[str] = Field(default_factory=list, max_length=100)
class AssuranceEdgeRevisionRequest(AssuranceEdgeWrite):
expected_revision: int = Field(ge=1)
class AssuranceEdgeResponse(AssuranceEdgeWrite):
id: str
revision: int
previous_revision_id: str | None
recorded_at: datetime
superseded_at: datetime | None
created_by: str | None
created_at: datetime
updated_at: datetime
class AssuranceEdgeListResponse(BaseModel):
edges: list[AssuranceEdgeResponse]
class AssuranceGraphResponse(BaseModel):
root_ref: str
nodes: list[AssuranceNodeResponse]
edges: list[AssuranceEdgeResponse]
truncated: bool
class AssuranceSummaryResponse(BaseModel):
node_count: int
edge_count: int
by_kind: dict[str, int]
by_state: dict[str, int]
__all__ = [
"AssuranceEdgeListResponse",
"AssuranceEdgeResponse",
"AssuranceEdgeRevisionRequest",
"AssuranceEdgeWrite",
"AssuranceGraphResponse",
"AssuranceNodeListResponse",
"AssuranceNodeResponse",
"AssuranceNodeRevisionRequest",
"AssuranceNodeWrite",
"AssuranceSummaryResponse",
"CandidateDetailResponse",
"CandidateResponse",
"ConnectorSnapshotListResponse",
@@ -152,11 +152,12 @@ def run_screening(
raise RiskSanctionsConflictError(
"Idempotency key was already used for another screening request."
)
return get_screening_run(
return _screening_result(
session,
principal,
run_id=existing.id,
), False
created=False,
)
list_snapshot = _visible_list_snapshot(
session,
@@ -207,11 +208,12 @@ def run_screening(
run.outcome = list_state
run.completed_at = utcnow()
session.flush()
return get_screening_run(
return _screening_result(
session,
principal,
run_id=run.id,
), True
created=True,
)
entries = tuple(
session.scalars(
@@ -235,11 +237,12 @@ def run_screening(
}
run.completed_at = utcnow()
session.flush()
return get_screening_run(
return _screening_result(
session,
principal,
run_id=run.id,
), True
created=True,
)
candidates = sorted(
(
@@ -297,11 +300,12 @@ def run_screening(
run.outcome = "potential" if candidates else "clear"
run.completed_at = utcnow()
session.flush()
return get_screening_run(
return _screening_result(
session,
principal,
run_id=run.id,
), True
created=True,
)
def screening_evidence_ref(run_id: str) -> str:
@@ -313,6 +317,31 @@ def screening_evidence_ref(run_id: str) -> str:
return f"{SCREENING_EVIDENCE_PREFIX}{clean_run_id}"
def _screening_result(
session: Session,
principal: ApiPrincipal,
*,
run_id: str,
created: bool,
) -> tuple[RiskScreeningRun, bool]:
run = get_screening_run(
session,
principal,
run_id=run_id,
)
if run.status == "completed":
from govoplan_risk_compliance.backend.assurance import (
record_sanctions_assurance,
)
record_sanctions_assurance(
session,
principal,
run=run,
)
return run, created
def assess_screening_freshness(
session: Session,
principal: ApiPrincipal,
@@ -0,0 +1,181 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_risk_compliance.backend.db.models import RiskAssuranceNode
from govoplan_risk_compliance.backend.permissions import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
PROVIDER_ID = "risk_compliance.assurance"
RESOURCE_TYPE = "risk_assurance_node"
class RiskAssuranceSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="risk_compliance",
resource_type=RESOURCE_TYPE,
label="Assurance graph",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
db = _session(session)
if (
request.provider_id != PROVIDER_ID
or request.resource_type != RESOURCE_TYPE
):
raise ValueError("Unsupported Risk Compliance search source.")
statement = select(RiskAssuranceNode).where(
RiskAssuranceNode.tenant_id == request.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
if request.cursor:
statement = statement.where(
RiskAssuranceNode.stable_id > request.cursor
)
rows = list(
db.scalars(
statement.order_by(RiskAssuranceNode.stable_id).limit(
request.limit + 1
)
)
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(RiskAssuranceNode.recorded_at)).where(
RiskAssuranceNode.tenant_id == request.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
)
return SearchBackfillPage(
documents=tuple(_document(item) for item in selected),
next_cursor=(
selected[-1].stable_id
if has_more and selected
else None
),
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {request.reference.key: False for request in requests}
if not isinstance(principal, ApiPrincipal) or not any(
principal.has(scope)
for scope in (READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
):
return decisions
valid = tuple(
request
for request in requests
if request.reference.tenant_id == principal.tenant_id
and request.reference.module_id == "risk_compliance"
and request.reference.resource_type == RESOURCE_TYPE
)
if not valid:
return decisions
ids = {request.reference.resource_id for request in valid}
existing = set(
_session(session).scalars(
select(RiskAssuranceNode.stable_id).where(
RiskAssuranceNode.tenant_id == principal.tenant_id,
RiskAssuranceNode.stable_id.in_(ids),
RiskAssuranceNode.superseded_at.is_(None),
)
)
)
for request in valid:
decisions[request.reference.key] = (
request.reference.resource_id in existing
)
return decisions
def create_risk_assurance_search_source(
_context: ModuleContext,
) -> RiskAssuranceSearchSource:
return RiskAssuranceSearchSource()
def _document(item: RiskAssuranceNode) -> SearchDocument:
return SearchDocument(
tenant_id=item.tenant_id,
module_id="risk_compliance",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=item.stable_id,
title=item.label,
url=(
"/risk-compliance?view=assurance&node="
f"{quote(item.stable_id, safe='')}"
),
summary=(
item.description
if item.classification in {"public", "internal"}
else None
),
keywords=(item.kind, item.state, item.classification),
visibility="tenant",
source_revision=str(item.revision),
source_updated_at=item.recorded_at,
metadata={
"kind": item.kind,
"state": item.state,
"classification": item.classification,
"revision": item.revision,
},
requires_authorization_recheck=True,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError(
"Risk assurance search requires a SQLAlchemy session."
)
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"RiskAssuranceSearchSource",
"create_risk_assurance_search_source",
]