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
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Risk Compliance Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Risk Compliance internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope
This repository owns the GovOPlaN Risk Compliance platform module seed.
+8 -3
View File
@@ -12,9 +12,12 @@ The first complete vertical is sanctions screening. Connectors acquires immutabl
The module includes database migrations, tenant-isolated APIs, an operational
WebUI, append-only dispositions, time-bounded false-positive exceptions,
freshness reconciliation, explicit consumer gates, and audit events. Queue and
audit summaries deliberately retain only the minimum subject data needed for
the workflow.
freshness reconciliation, explicit consumer gates, and audit events. It also
provides a revisioned horizontal assurance graph from obligation through
effectiveness review, with bounded traversal, opaque governed-object links,
search, and optimistic concurrency. Queue, search, audit, and aggregate
summaries deliberately retain only the minimum subject data needed for their
purpose.
## Initial Ownership
@@ -27,6 +30,8 @@ the workflow.
- immutable sanctions list catalogues
- sanctions screening and reviewer dispositions
- stable screening evidence references and freshness gates
- effective-dated obligations, risks, controls, evidence, findings, corrective
measures, and effectiveness reviews
## Boundaries
+51
View File
@@ -16,12 +16,17 @@ Risk and compliance workflows for data protection incidents, DPIAs, compliance c
- version-pinned screening runs and candidate explanations
- reviewer dispositions, exceptions, and screening freshness
- block, review, and degraded screening-gate decisions
- reusable assurance relationships from obligation through governed object,
risk, control, evidence, finding, corrective measure, and effectiveness
review
## Does Not Own
- immutable audit log storage
- records retention engine
- inspection fieldwork
- domain-object lifecycle and corrective execution owned by the affected module
- policy rule evaluation or immutable audit-log storage
## Integration Candidates
@@ -66,3 +71,49 @@ record.
Fuzzy candidates always require review. A confirmed match blocks; a set of
independently cleared false positives allows; source or execution uncertainty
uses the consuming module's configured failure policy.
## Horizontal Assurance Graph
Sanctions screening is the first complete assurance vertical, not the whole
module model. Risk Compliance persists the reusable graph:
```text
Obligation -> governed object -> risk -> control -> evidence -> finding -> measure -> effectiveness review
```
Every node and relationship has a stable tenant-local identity, immutable
revision history, effective and recorded time, owner and optional scope,
provenance, legal/policy/evidence references, and optimistic-concurrency
guards. Corrections create a new revision; the historical assertion is not
rewritten or deleted. Governed-object nodes retain only an opaque reference to
the object owned by another module.
The tenant API exposes bounded current-object listing, revision histories,
aggregate counts, and graph traversal. Traversal is intentionally limited to
eight relationships and 500 returned objects. Search indexes only current
revisions, rechecks tenant access at result time, and omits confidential
descriptions. Aggregate responses contain counts, never labels or opaque
references.
Completed sanctions screenings automatically project obligation, party,
exposure risk, version-pinned control, immutable evidence, and finding nodes.
This projection is idempotent and includes unsuccessful or stale screening
outcomes so operational uncertainty remains visible. The non-sanctions test
fixture proves the entire chain through corrective measure and effectiveness
review without coupling the graph to sanctions data.
Policy may advise or block based on assurance state; Audit records events;
Files/Records retain referenced evidence; Tasks/Workflow may coordinate review
and correction. Risk Compliance owns risk, control, finding, and effectiveness
semantics, but the affected domain module remains authoritative for its object
and corrective execution.
### Access, recovery, and retirement
Read, write, and administration use the Risk Compliance workspace scopes and
are always tenant-bound. Generated sanctions evidence additionally keeps the
sanctions permissions on its source workflow. Database backup and restore is
the recovery mechanism for the immutable graph; migration verification covers
both graph tables. Module retirement is destructive and therefore requires a
database snapshot before graph, source, screening, and review evidence is
removed.
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",
+135 -21
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()
),
}
@@ -228,6 +314,10 @@ DOCUMENTATION = (
"owns immutable normalized sanctions lists, version-pinned "
"screening, candidate review, and legal dispositions. Fuzzy "
"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",
]
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.db.base import Base
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.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
)
from govoplan_risk_compliance.backend.permissions import (
READ_SCOPE,
WRITE_SCOPE,
)
from govoplan_risk_compliance.backend.search_source import (
PROVIDER_ID,
RESOURCE_TYPE,
RiskAssuranceSearchSource,
)
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
TABLES = (
RiskAssuranceNode.__table__,
RiskAssuranceEdge.__table__,
)
def principal(
tenant_id: str = "tenant-1",
*,
scopes: tuple[str, ...] = (READ_SCOPE, WRITE_SCOPE),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=f"account-{tenant_id}",
membership_id=f"membership-{tenant_id}",
tenant_id=tenant_id,
scopes=frozenset(scopes),
),
account=SimpleNamespace(id=f"account-{tenant_id}"),
user=SimpleNamespace(id=f"account-{tenant_id}"),
)
class AssuranceGraphTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=TABLES)
self.session = Session(self.engine)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_non_sanctions_control_uses_complete_revisioned_graph(self) -> None:
node_specs = (
("retention-obligation", "obligation", "active"),
("customer-register", "governed_object", "active"),
("over-retention-risk", "risk", "identified"),
("retention-control", "control", "implemented"),
("retention-evidence", "evidence", "current"),
("retention-finding", "finding", "open"),
("retention-measure", "corrective_measure", "planned"),
("retention-review", "effectiveness_review", "pending"),
)
nodes = {}
for stable_id, kind, state in node_specs:
nodes[stable_id] = create_assurance_node(
self.session,
principal(),
value=AssuranceNodeInput(
stable_id=stable_id,
kind=kind,
label=stable_id.replace("-", " ").title(),
state=state,
owner_ref="organizations:function:data-governance",
scope_ref="datasource:customer-register",
governed_object_ref=(
"datasources:customer-register"
if kind == "governed_object"
else None
),
valid_from=NOW,
provenance={"fixture": "non-sanctions"},
legal_basis_refs=("law:retention:2026",),
),
)
relations = (
("retention-obligation", "applies_to", "customer-register"),
("customer-register", "exposes_risk", "over-retention-risk"),
("over-retention-risk", "mitigated_by", "retention-control"),
("retention-control", "evidenced_by", "retention-evidence"),
("retention-evidence", "results_in", "retention-finding"),
("retention-finding", "addressed_by", "retention-measure"),
("retention-measure", "reviewed_by", "retention-review"),
)
edges = []
for index, (source, relation, target) in enumerate(relations, start=1):
edges.append(
create_assurance_edge(
self.session,
principal(),
value=AssuranceEdgeInput(
stable_id=f"retention-edge-{index}",
source_node_ref=source,
target_node_ref=target,
relation=relation,
owner_ref="organizations:function:data-governance",
valid_from=NOW,
provenance={"fixture": "non-sanctions"},
),
)
)
self.session.commit()
graph = assurance_graph(
self.session,
principal(),
root_ref="retention-obligation",
max_depth=8,
limit=100,
)
summary = assurance_summary(self.session, principal())
self.assertEqual(8, len(graph.nodes))
self.assertEqual(7, len(graph.edges))
self.assertFalse(graph.truncated)
self.assertEqual(8, summary["node_count"])
self.assertEqual(7, summary["edge_count"])
self.assertEqual(1, summary["by_kind"]["effectiveness_review"])
self.assertEqual("control", nodes["retention-control"].kind)
self.assertEqual("reviewed_by", edges[-1].relation)
def test_node_and_edge_revisions_are_immutable_and_occ_guarded(self) -> None:
risk = self._node("risk-1", "risk", "identified")
control = self._node("control-1", "control", "implemented")
edge = create_assurance_edge(
self.session,
principal(),
value=AssuranceEdgeInput(
stable_id="edge-1",
source_node_ref=risk.stable_id,
target_node_ref=control.stable_id,
relation="mitigated_by",
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
revised = revise_assurance_node(
self.session,
principal(),
stable_id=risk.stable_id,
expected_revision=1,
value=AssuranceNodeInput(
stable_id=risk.stable_id,
kind="risk",
label="Risk 1",
state="assessed",
owner_ref="function:risk-owner",
valid_from=NOW,
evidence_refs=("evidence:assessment-1",),
),
)
revised_edge = revise_assurance_edge(
self.session,
principal(),
stable_id=edge.stable_id,
expected_revision=1,
value=AssuranceEdgeInput(
stable_id=edge.stable_id,
source_node_ref=risk.stable_id,
target_node_ref=control.stable_id,
relation="mitigated_by",
state="suspended",
owner_ref="function:risk-owner",
valid_from=NOW,
evidence_refs=("evidence:suspension-1",),
),
)
self.session.commit()
self.assertEqual(2, revised.revision)
self.assertEqual(2, revised_edge.revision)
self.assertEqual(
[2, 1],
[
item.revision
for item in list_assurance_node_history(
self.session,
principal(),
stable_id="risk-1",
)
],
)
self.assertEqual(
[2, 1],
[
item.revision
for item in list_assurance_edge_history(
self.session,
principal(),
stable_id="edge-1",
)
],
)
with self.assertRaisesRegex(
RiskAssuranceConflictError,
"current revision is 2",
):
revise_assurance_node(
self.session,
principal(),
stable_id="risk-1",
expected_revision=1,
value=AssuranceNodeInput(
stable_id="risk-1",
kind="risk",
label="Risk 1",
state="closed",
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
def test_access_and_tenant_boundaries_are_enforced(self) -> None:
self._node("risk-1", "risk", "identified")
self.session.commit()
self.assertEqual(
(),
list_assurance_nodes(self.session, principal("tenant-2")),
)
with self.assertRaises(RiskAssuranceNotFoundError):
assurance_graph(
self.session,
principal("tenant-2"),
root_ref="risk-1",
)
with self.assertRaises(RiskAssuranceAccessError):
list_assurance_nodes(
self.session,
principal(scopes=()),
)
def test_programmatic_inputs_enforce_text_and_provenance_bounds(self) -> None:
with self.assertRaisesRegex(RiskAssuranceError, "description is limited"):
AssuranceNodeInput(
stable_id="risk-oversized-description",
kind="risk",
label="Oversized risk",
state="identified",
owner_ref="function:risk-owner",
valid_from=NOW,
description="x" * 20_001,
)
with self.assertRaisesRegex(RiskAssuranceError, "provenance is limited"):
AssuranceNodeInput(
stable_id="risk-oversized-provenance",
kind="risk",
label="Oversized provenance",
state="identified",
owner_ref="function:risk-owner",
valid_from=NOW,
provenance={"payload": "x" * 100_001},
)
def test_relation_shape_is_validated(self) -> None:
self._node("risk-1", "risk", "identified")
self._node("evidence-1", "evidence", "current")
with self.assertRaisesRegex(
RiskAssuranceConflictError,
"requires risk -> control",
):
create_assurance_edge(
self.session,
principal(),
value=AssuranceEdgeInput(
stable_id="invalid-edge",
source_node_ref="risk-1",
target_node_ref="evidence-1",
relation="mitigated_by",
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
def test_search_backfill_and_authorization_are_tenant_safe(self) -> None:
self._node("risk-1", "risk", "identified")
self.session.commit()
provider = RiskAssuranceSearchSource()
page = provider.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
request = SearchAuthorizationRequest(
reference=SearchResourceReference(
tenant_id="tenant-1",
module_id="risk_compliance",
resource_type=RESOURCE_TYPE,
resource_id="risk-1",
),
source_revision="1",
)
self.assertEqual(1, len(page.documents))
self.assertTrue(
provider.authorize(
self.session,
principal(),
requests=(request,),
)[request.reference.key]
)
self.assertFalse(
provider.authorize(
self.session,
principal("tenant-2"),
requests=(request,),
)[request.reference.key]
)
def _node(
self,
stable_id: str,
kind: str,
state: str,
) -> RiskAssuranceNode:
return create_assurance_node(
self.session,
principal(),
value=AssuranceNodeInput(
stable_id=stable_id,
kind=kind,
label=stable_id.replace("-", " ").title(),
state=state,
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -48,6 +48,11 @@ class ManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
self.assertIn(
"governance_overlay",
manifest.architecture.supported_authority_modes,
)
self.assertEqual(
"connectors.sanctions_snapshots",
manifest.requires_interfaces[0].name,
+3 -1
View File
@@ -26,7 +26,7 @@ class RiskComplianceMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"a8b9c0d1e2f3",
"b9c0d1e2f3a4",
set(
MigrationContext.configure(
connection
@@ -49,6 +49,8 @@ class RiskComplianceMigrationTests(unittest.TestCase):
"risk_screening_exceptions",
tables,
)
self.assertIn("risk_assurance_nodes", tables)
self.assertIn("risk_assurance_edges", tables)
finally:
engine.dispose()
+28 -1
View File
@@ -22,6 +22,8 @@ from govoplan_core.core.sanctions import (
)
from govoplan_core.db.base import Base, utcnow
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskSanctionsAddress,
RiskSanctionsAlias,
RiskSanctionsDate,
@@ -104,6 +106,8 @@ UN_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
"""
TABLES = (
RiskAssuranceNode.__table__,
RiskAssuranceEdge.__table__,
RiskSanctionsListSnapshot.__table__,
RiskSanctionsEntry.__table__,
RiskSanctionsAlias.__table__,
@@ -254,6 +258,27 @@ class SanctionsScreeningTests(unittest.TestCase):
self.assertFalse(
item.candidates[0].evidence[0]["auto_confirmed"]
)
self.assertEqual(
6,
self.session.query(RiskAssuranceNode)
.filter(
RiskAssuranceNode.tenant_id == "tenant-1",
RiskAssuranceNode.superseded_at.is_(None),
)
.count(),
)
self.assertEqual(
5,
self.session.query(RiskAssuranceEdge)
.filter(
RiskAssuranceEdge.tenant_id == "tenant-1",
RiskAssuranceEdge.superseded_at.is_(None),
)
.count(),
)
item_id = item.id
self.session.commit()
self.session.expire_all()
replay, replay_created = run_screening(
self.session,
@@ -266,7 +291,9 @@ class SanctionsScreeningTests(unittest.TestCase):
),
)
self.assertFalse(replay_created)
self.assertEqual(item.id, replay.id)
self.assertEqual(item_id, replay.id)
self.assertEqual(6, self.session.query(RiskAssuranceNode).count())
self.assertEqual(5, self.session.query(RiskAssuranceEdge).count())
with self.assertRaises(RiskSanctionsConflictError):
run_screening(
+182
View File
@@ -145,6 +145,97 @@ export type CandidateDetail = {
};
};
export type AssuranceNodeKind =
| "obligation"
| "governed_object"
| "risk"
| "control"
| "evidence"
| "finding"
| "corrective_measure"
| "effectiveness_review";
export type AssuranceNode = {
id: string;
stable_id: string;
kind: AssuranceNodeKind;
revision: number;
previous_revision_id?: string | null;
label: string;
description?: string | null;
state: string;
owner_ref: string;
scope_ref?: string | null;
governed_object_ref?: string | null;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
provenance: Record<string, unknown>;
legal_basis_refs: string[];
policy_refs: string[];
evidence_refs: string[];
classification: string;
created_by?: string | null;
created_at: string;
updated_at: string;
};
export type AssuranceEdge = {
id: string;
stable_id: string;
revision: number;
previous_revision_id?: string | null;
source_node_ref: string;
target_node_ref: string;
relation: string;
state: "active" | "suspended" | "retired";
owner_ref: string;
scope_ref?: string | null;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
provenance: Record<string, unknown>;
legal_basis_refs: string[];
policy_refs: string[];
evidence_refs: string[];
created_by?: string | null;
created_at: string;
updated_at: string;
};
export type AssuranceSummary = {
node_count: number;
edge_count: number;
by_kind: Record<string, number>;
by_state: Record<string, number>;
};
export type AssuranceNodeWrite = Omit<
AssuranceNode,
| "id"
| "revision"
| "previous_revision_id"
| "recorded_at"
| "superseded_at"
| "created_by"
| "created_at"
| "updated_at"
>;
export type AssuranceEdgeWrite = Omit<
AssuranceEdge,
| "id"
| "revision"
| "previous_revision_id"
| "recorded_at"
| "superseded_at"
| "created_by"
| "created_at"
| "updated_at"
>;
export async function listConnectorSnapshots(
settings: ApiSettings
) {
@@ -251,3 +342,94 @@ export async function createDisposition(
}
);
}
export async function listAssuranceNodes(
settings: ApiSettings,
filters: {
kind?: string;
state?: string;
query?: string;
governedObjectRef?: string;
} = {}
) {
return apiFetch<{ nodes: AssuranceNode[] }>(
settings,
apiPath("/api/v1/risk-compliance/assurance/nodes", {
kind: filters.kind,
state: filters.state,
query: filters.query,
governed_object_ref: filters.governedObjectRef,
limit: 500
})
);
}
export async function getAssuranceSummary(settings: ApiSettings) {
return apiFetch<AssuranceSummary>(
settings,
"/api/v1/risk-compliance/assurance/summary"
);
}
export async function getAssuranceGraph(
settings: ApiSettings,
rootRef: string
) {
return apiFetch<{
root_ref: string;
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
}>(
settings,
apiPath("/api/v1/risk-compliance/assurance/graph", {
root_ref: rootRef,
max_depth: 8,
limit: 500
})
);
}
export async function saveAssuranceNode(
settings: ApiSettings,
value: AssuranceNodeWrite,
expectedRevision?: number
) {
return apiFetch<AssuranceNode>(
settings,
expectedRevision
? "/api/v1/risk-compliance/assurance/nodes/revise"
: "/api/v1/risk-compliance/assurance/nodes",
{
method: "POST",
body: JSON.stringify({
...value,
...(expectedRevision
? { expected_revision: expectedRevision }
: {})
})
}
);
}
export async function saveAssuranceEdge(
settings: ApiSettings,
value: AssuranceEdgeWrite,
expectedRevision?: number
) {
return apiFetch<AssuranceEdge>(
settings,
expectedRevision
? "/api/v1/risk-compliance/assurance/edges/revise"
: "/api/v1/risk-compliance/assurance/edges",
{
method: "POST",
body: JSON.stringify({
...value,
...(expectedRevision
? { expected_revision: expectedRevision }
: {})
})
}
);
}
@@ -1,7 +1,10 @@
import {
CheckCircle2,
Database,
Network,
Pencil,
Play,
Plus,
RefreshCw,
Scale,
Upload
@@ -13,6 +16,7 @@ import {
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router";
import {
Button,
Dialog,
@@ -28,12 +32,23 @@ import {
} from "@govoplan/core-webui";
import {
createDisposition,
getAssuranceGraph,
getAssuranceSummary,
getCandidate,
importListSnapshot,
listConnectorSnapshots,
listAssuranceNodes,
listListSnapshots,
listReviewQueue,
runScreening,
saveAssuranceEdge,
saveAssuranceNode,
type AssuranceEdge,
type AssuranceEdgeWrite,
type AssuranceNode,
type AssuranceNodeKind,
type AssuranceNodeWrite,
type AssuranceSummary,
type CandidateDetail,
type ConnectorSnapshot,
type ListSnapshot,
@@ -42,13 +57,18 @@ import {
} from "../../api/riskCompliance";
type ViewMode = "sources" | "screen" | "review";
type ViewMode = "sources" | "screen" | "review" | "assurance";
export default function RiskCompliancePage({
settings,
auth
}: PlatformRouteContext) {
const [view, setView] = useState<ViewMode>("review");
const [searchParams] = useSearchParams();
const requestedView = searchParams.get("view");
const requestedAssuranceId = searchParams.get("node")?.trim() ?? "";
const [view, setView] = useState<ViewMode>(() =>
requestedView === "assurance" ? "assurance" : "review"
);
const [sourceSnapshots, setSourceSnapshots] = useState<
ConnectorSnapshot[]
>([]);
@@ -58,6 +78,17 @@ export default function RiskCompliancePage({
const [selectedCandidateId, setSelectedCandidateId] = useState("");
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
const [run, setRun] = useState<ScreeningRun | null>(null);
const [assuranceNodes, setAssuranceNodes] = useState<AssuranceNode[]>([]);
const [assuranceSummary, setAssuranceSummary] =
useState<AssuranceSummary | null>(null);
const [selectedAssuranceId, setSelectedAssuranceId] = useState(
requestedAssuranceId
);
const [assuranceGraph, setAssuranceGraph] = useState<{
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -74,34 +105,71 @@ export default function RiskCompliancePage({
auth,
"risk_compliance:sanctions:review"
);
const canReadAssurance = [
"risk_compliance:workspace:read",
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
const canWriteAssurance = [
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
useEffect(() => {
if (requestedView === "assurance" && canReadAssurance) {
setView("assurance");
if (requestedAssuranceId) {
setSelectedAssuranceId(requestedAssuranceId);
}
}
}, [canReadAssurance, requestedAssuranceId, requestedView]);
useEffect(() => {
if (view === "review" && !canReview) {
setView(canReadAssurance ? "assurance" : "sources");
} else if (view === "assurance" && !canReadAssurance) {
setView(canReview ? "review" : "sources");
}
}, [canReadAssurance, canReview, view]);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [sources, lists, reviewQueue] = await Promise.all([
const [sources, lists, reviewQueue, assurance] = await Promise.all([
listConnectorSnapshots(settings),
listListSnapshots(settings),
canReview
? listReviewQueue(settings)
: Promise.resolve({ candidates: [] })
: Promise.resolve({ candidates: [] }),
canReadAssurance
? Promise.all([
listAssuranceNodes(settings),
getAssuranceSummary(settings)
]).then(([nodes, summary]) => ({ nodes: nodes.nodes, summary }))
: Promise.resolve({ nodes: [], summary: null })
]);
setSourcesAvailable(sources.available);
setSourceSnapshots(sources.snapshots);
setListSnapshots(lists.snapshots);
setQueue(reviewQueue.candidates);
setAssuranceNodes(assurance.nodes);
setAssuranceSummary(assurance.summary);
setSelectedCandidateId((current) =>
current &&
reviewQueue.candidates.some((item) => item.id === current)
? current
: reviewQueue.candidates[0]?.id ?? ""
);
setSelectedAssuranceId((current) =>
current || assurance.nodes[0]?.stable_id || ""
);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}, [canReview, settings]);
}, [canReadAssurance, canReview, settings]);
useEffect(() => {
void refresh();
@@ -125,6 +193,24 @@ export default function RiskCompliancePage({
};
}, [selectedCandidateId, settings]);
useEffect(() => {
if (!selectedAssuranceId || !canReadAssurance) {
setAssuranceGraph(null);
return;
}
let cancelled = false;
void getAssuranceGraph(settings, selectedAssuranceId)
.then((graph) => {
if (!cancelled) setAssuranceGraph(graph);
})
.catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
return () => {
cancelled = true;
};
}, [canReadAssurance, selectedAssuranceId, settings]);
async function importSnapshot(item: ConnectorSnapshot) {
setBusy(true);
setError("");
@@ -182,6 +268,16 @@ export default function RiskCompliancePage({
</>
),
disabled: !canReview
},
{
id: "assurance",
label: (
<>
<Network size={15} />
Assurance
</>
),
disabled: !canReadAssurance
}
]}
/>
@@ -250,6 +346,22 @@ export default function RiskCompliancePage({
settings={settings}
/>
)}
{view === "assurance" && (
<AssurancePane
nodes={assuranceNodes}
summary={assuranceSummary}
graph={assuranceGraph}
selectedId={selectedAssuranceId}
canWrite={canWriteAssurance}
busy={busy}
settings={settings}
onSelect={setSelectedAssuranceId}
onBusy={setBusy}
onError={setError}
onNotice={setNotice}
onRefresh={refresh}
/>
)}
</div>
</main>
);
@@ -754,6 +866,605 @@ function ReviewPane({
);
}
const ASSURANCE_STATES: Record<AssuranceNodeKind, string[]> = {
obligation: ["active", "suspended", "retired"],
governed_object: ["active", "inactive", "retired"],
risk: ["identified", "assessed", "accepted", "mitigated", "closed"],
control: [
"designed",
"implemented",
"effective",
"failed",
"suspended",
"retired"
],
evidence: ["current", "stale", "invalid", "superseded"],
finding: ["open", "accepted", "exception", "remediating", "resolved"],
corrective_measure: ["planned", "in_progress", "completed", "cancelled"],
effectiveness_review: ["pending", "effective", "ineffective", "inconclusive"]
};
const ASSURANCE_RELATIONS = [
{ id: "applies_to", source: "obligation", target: "governed_object" },
{ id: "exposes_risk", source: "governed_object", target: "risk" },
{ id: "mitigated_by", source: "risk", target: "control" },
{ id: "evidenced_by", source: "control", target: "evidence" },
{ id: "results_in", source: "evidence", target: "finding" },
{ id: "addressed_by", source: "finding", target: "corrective_measure" },
{
id: "reviewed_by",
source: "corrective_measure",
target: "effectiveness_review"
}
] as const;
type AssuranceNodeDraft = {
stableId: string;
kind: AssuranceNodeKind;
label: string;
description: string;
state: string;
ownerRef: string;
scopeRef: string;
governedObjectRef: string;
validFrom: string;
validTo: string;
classification: string;
legalBasisRefs: string;
policyRefs: string;
evidenceRefs: string;
};
function AssurancePane({
nodes,
summary,
graph,
selectedId,
canWrite,
busy,
settings,
onSelect,
onBusy,
onError,
onNotice,
onRefresh
}: {
nodes: AssuranceNode[];
summary: AssuranceSummary | null;
graph: { nodes: AssuranceNode[]; edges: AssuranceEdge[]; truncated: boolean } | null;
selectedId: string;
canWrite: boolean;
busy: boolean;
settings: PlatformRouteContext["settings"];
onSelect: (id: string) => void;
onBusy: (value: boolean) => void;
onError: (message: string) => void;
onNotice: (message: string) => void;
onRefresh: () => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [edgeDialogOpen, setEdgeDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<AssuranceNode | null>(null);
const [nodeDraft, setNodeDraft] = useState<AssuranceNodeDraft>(
emptyAssuranceNodeDraft()
);
const [edgeRelation, setEdgeRelation] = useState("");
const [edgeTarget, setEdgeTarget] = useState("");
const selected = nodes.find((item) => item.stable_id === selectedId) ?? null;
const visibleNodes = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return nodes.filter(
(item) =>
(!kind || item.kind === kind) &&
(!needle ||
item.label.toLocaleLowerCase().includes(needle) ||
item.stable_id.toLocaleLowerCase().includes(needle) ||
(item.description || "").toLocaleLowerCase().includes(needle))
);
}, [kind, nodes, query]);
const availableRelations = selected
? ASSURANCE_RELATIONS.filter((item) => item.source === selected.kind)
: [];
const activeRelation = availableRelations.find(
(item) => item.id === edgeRelation
);
const edgeTargets = activeRelation
? nodes.filter((item) => item.kind === activeRelation.target)
: [];
function openNewNode() {
setEditingNode(null);
setNodeDraft(emptyAssuranceNodeDraft());
setNodeDialogOpen(true);
}
function openEditNode(item: AssuranceNode) {
setEditingNode(item);
setNodeDraft(nodeDraftFromItem(item));
setNodeDialogOpen(true);
}
async function submitNode(event: FormEvent) {
event.preventDefault();
onBusy(true);
onError("");
try {
const saved = await saveAssuranceNode(
settings,
assuranceNodeWrite(nodeDraft, editingNode?.provenance),
editingNode?.revision
);
setNodeDialogOpen(false);
onSelect(saved.stable_id);
onNotice(
editingNode
? `Recorded assurance revision ${saved.revision}.`
: "Created the assurance object."
);
await onRefresh();
} catch (reason) {
onError(errorMessage(reason));
} finally {
onBusy(false);
}
}
function openEdgeDialog() {
const first = availableRelations[0];
setEdgeRelation(first?.id ?? "");
setEdgeTarget(
first
? nodes.find((item) => item.kind === first.target)?.stable_id ?? ""
: ""
);
setEdgeDialogOpen(true);
}
async function submitEdge(event: FormEvent) {
event.preventDefault();
if (!selected || !activeRelation || !edgeTarget) return;
onBusy(true);
onError("");
const value: AssuranceEdgeWrite = {
stable_id: `edge-${crypto.randomUUID()}`,
source_node_ref: selected.stable_id,
target_node_ref: edgeTarget,
relation: activeRelation.id,
state: "active",
owner_ref: selected.owner_ref,
scope_ref: selected.scope_ref,
valid_from: new Date().toISOString(),
valid_to: null,
provenance: { source: "risk-compliance-ui" },
legal_basis_refs: selected.legal_basis_refs,
policy_refs: selected.policy_refs,
evidence_refs: []
};
try {
await saveAssuranceEdge(settings, value);
setEdgeDialogOpen(false);
onNotice("Connected the assurance objects.");
await onRefresh();
} catch (reason) {
onError(errorMessage(reason));
} finally {
onBusy(false);
}
}
return (
<section className="risk-assurance-layout">
<div className="risk-assurance-metrics">
<span><strong>{summary?.node_count ?? 0}</strong>objects</span>
<span><strong>{summary?.edge_count ?? 0}</strong>relationships</span>
<span><strong>{summary?.by_kind.risk ?? 0}</strong>risks</span>
<span><strong>{summary?.by_state.open ?? 0}</strong>open findings</span>
</div>
<div className="risk-assurance-columns">
<aside className="risk-panel">
<header>
<div>
<strong>Assurance objects</strong>
<span>Current effective revisions</span>
</div>
{canWrite && (
<IconButton
label="Add assurance object"
icon={<Plus size={16} />}
onClick={openNewNode}
/>
)}
</header>
<div className="risk-assurance-filter">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search assurance objects"
aria-label="Search assurance objects"
/>
<select
value={kind}
onChange={(event) => setKind(event.target.value)}
aria-label="Filter by assurance type"
>
<option value="">All types</option>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</div>
<div className="risk-list">
{visibleNodes.map((item) => (
<button
type="button"
className={
item.stable_id === selectedId
? "risk-queue-row selected"
: "risk-queue-row"
}
key={item.stable_id}
onClick={() => onSelect(item.stable_id)}
>
<span className="risk-list-main">
<strong>{item.label}</strong>
<span>{formatToken(item.kind)} · revision {item.revision}</span>
</span>
<StatusBadge status={item.state} />
</button>
))}
{!visibleNodes.length && (
<div className="risk-empty">No assurance objects match.</div>
)}
</div>
</aside>
<div className="risk-panel risk-assurance-detail">
<header>
<div>
<strong>{selected?.label || "Assurance object"}</strong>
<span>{selected ? formatToken(selected.kind) : "Select an object"}</span>
</div>
{selected && canWrite && !selected.stable_id.startsWith("sanctions-") && (
<IconButton
label="Edit assurance object"
icon={<Pencil size={16} />}
onClick={() => openEditNode(selected)}
/>
)}
</header>
{!selected && (
<div className="risk-empty">Select an assurance object.</div>
)}
{selected && (
<div className="risk-assurance-detail-body">
<dl className="risk-assurance-properties">
<div><dt>State</dt><dd><StatusBadge status={selected.state} /></dd></div>
<div><dt>Owner</dt><dd>{selected.owner_ref}</dd></div>
<div><dt>Scope</dt><dd>{selected.scope_ref || "Tenant"}</dd></div>
<div><dt>Valid from</dt><dd>{formatDate(selected.valid_from)}</dd></div>
{selected.governed_object_ref && (
<div><dt>Governed object</dt><dd><code>{selected.governed_object_ref}</code></dd></div>
)}
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
</dl>
{selected.description && <p>{selected.description}</p>}
<div className="risk-assurance-links-header">
<div>
<strong>Relationships</strong>
<span>{graph?.edges.length ?? 0} in the bounded graph</span>
</div>
{canWrite && availableRelations.length > 0 && (
<Button onClick={openEdgeDialog} disabled={busy}>
<Plus size={16} />
Connect
</Button>
)}
</div>
<div className="risk-assurance-links">
{graph?.edges.map((edge) => (
<AssuranceEdgeRow
edge={edge}
nodes={graph.nodes}
selectedId={selected.stable_id}
onSelect={onSelect}
key={edge.stable_id}
/>
))}
{!graph?.edges.length && (
<div className="risk-empty">No relationships are recorded.</div>
)}
</div>
{graph?.truncated && (
<div className="risk-assurance-truncated">
The bounded graph contains additional relationships.
</div>
)}
</div>
)}
</div>
</div>
<AssuranceNodeDialog
open={nodeDialogOpen}
busy={busy}
editing={Boolean(editingNode)}
draft={nodeDraft}
onChange={setNodeDraft}
onClose={() => setNodeDialogOpen(false)}
onSubmit={submitNode}
/>
<Dialog
open={edgeDialogOpen}
title="Connect assurance objects"
onClose={() => setEdgeDialogOpen(false)}
closeDisabled={busy}
footer={
<>
<Button onClick={() => setEdgeDialogOpen(false)} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-edge-form"
type="submit"
variant="primary"
disabled={busy || !activeRelation || !edgeTarget}
>
Connect
</Button>
</>
}
>
<form id="risk-assurance-edge-form" className="risk-assurance-form" onSubmit={submitEdge}>
<FormField label="Relationship">
<select
value={edgeRelation}
onChange={(event) => {
const relation = event.target.value;
setEdgeRelation(relation);
const shape = availableRelations.find((item) => item.id === relation);
setEdgeTarget(
shape
? nodes.find((item) => item.kind === shape.target)?.stable_id ?? ""
: ""
);
}}
>
{availableRelations.map((item) => (
<option value={item.id} key={item.id}>{formatToken(item.id)}</option>
))}
</select>
</FormField>
<FormField label="Target object">
<select value={edgeTarget} onChange={(event) => setEdgeTarget(event.target.value)}>
{edgeTargets.map((item) => (
<option value={item.stable_id} key={item.stable_id}>{item.label}</option>
))}
</select>
</FormField>
</form>
</Dialog>
</section>
);
}
function AssuranceEdgeRow({
edge,
nodes,
selectedId,
onSelect
}: {
edge: AssuranceEdge;
nodes: AssuranceNode[];
selectedId: string;
onSelect: (id: string) => void;
}) {
const otherId = edge.source_node_ref === selectedId
? edge.target_node_ref
: edge.source_node_ref;
const other = nodes.find((item) => item.stable_id === otherId);
return (
<button type="button" onClick={() => onSelect(otherId)}>
<span>{formatToken(edge.relation)}</span>
<strong>{other?.label || otherId}</strong>
<StatusBadge status={edge.state} />
</button>
);
}
function AssuranceNodeDialog({
open,
busy,
editing,
draft,
onChange,
onClose,
onSubmit
}: {
open: boolean;
busy: boolean;
editing: boolean;
draft: AssuranceNodeDraft;
onChange: (value: AssuranceNodeDraft) => void;
onClose: () => void;
onSubmit: (event: FormEvent) => void;
}) {
const update = (values: Partial<AssuranceNodeDraft>) =>
onChange({ ...draft, ...values });
return (
<Dialog
open={open}
title={editing ? "Revise assurance object" : "Add assurance object"}
onClose={onClose}
closeDisabled={busy}
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-node-form"
type="submit"
variant="primary"
disabled={busy}
>
{editing ? "Record revision" : "Add"}
</Button>
</>
}
>
<form id="risk-assurance-node-form" className="risk-assurance-form" onSubmit={onSubmit}>
<div className="risk-assurance-form-grid">
<FormField label="Stable ID">
<input
value={draft.stableId}
onChange={(event) => update({ stableId: event.target.value })}
disabled={editing}
required
maxLength={255}
/>
</FormField>
<FormField label="Type">
<select
value={draft.kind}
onChange={(event) => {
const nextKind = event.target.value as AssuranceNodeKind;
update({ kind: nextKind, state: ASSURANCE_STATES[nextKind][0] });
}}
disabled={editing}
>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Name">
<input value={draft.label} onChange={(event) => update({ label: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="State">
<select value={draft.state} onChange={(event) => update({ state: event.target.value })}>
{ASSURANCE_STATES[draft.kind].map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Owner reference">
<input value={draft.ownerRef} onChange={(event) => update({ ownerRef: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="Scope reference">
<input value={draft.scopeRef} onChange={(event) => update({ scopeRef: event.target.value })} maxLength={500} />
</FormField>
{draft.kind === "governed_object" && (
<FormField label="Governed object reference">
<input value={draft.governedObjectRef} onChange={(event) => update({ governedObjectRef: event.target.value })} required maxLength={1000} />
</FormField>
)}
<FormField label="Classification">
<select value={draft.classification} onChange={(event) => update({ classification: event.target.value })}>
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</FormField>
<FormField label="Valid from">
<input type="datetime-local" value={draft.validFrom} onChange={(event) => update({ validFrom: event.target.value })} required />
</FormField>
<FormField label="Valid to">
<input type="datetime-local" value={draft.validTo} onChange={(event) => update({ validTo: event.target.value })} />
</FormField>
</div>
<FormField label="Description">
<textarea value={draft.description} onChange={(event) => update({ description: event.target.value })} rows={4} maxLength={20000} />
</FormField>
<div className="risk-assurance-form-grid">
<FormField label="Legal basis references">
<textarea value={draft.legalBasisRefs} onChange={(event) => update({ legalBasisRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Policy references">
<textarea value={draft.policyRefs} onChange={(event) => update({ policyRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Evidence references">
<textarea value={draft.evidenceRefs} onChange={(event) => update({ evidenceRefs: event.target.value })} rows={3} />
</FormField>
</div>
</form>
</Dialog>
);
}
function emptyAssuranceNodeDraft(): AssuranceNodeDraft {
return {
stableId: "",
kind: "obligation",
label: "",
description: "",
state: "active",
ownerRef: "",
scopeRef: "",
governedObjectRef: "",
validFrom: dateTimeLocalValue(new Date()),
validTo: "",
classification: "internal",
legalBasisRefs: "",
policyRefs: "",
evidenceRefs: ""
};
}
function nodeDraftFromItem(item: AssuranceNode): AssuranceNodeDraft {
return {
stableId: item.stable_id,
kind: item.kind,
label: item.label,
description: item.description || "",
state: item.state,
ownerRef: item.owner_ref,
scopeRef: item.scope_ref || "",
governedObjectRef: item.governed_object_ref || "",
validFrom: dateTimeLocalValue(new Date(item.valid_from)),
validTo: item.valid_to ? dateTimeLocalValue(new Date(item.valid_to)) : "",
classification: item.classification,
legalBasisRefs: item.legal_basis_refs.join("\n"),
policyRefs: item.policy_refs.join("\n"),
evidenceRefs: item.evidence_refs.join("\n")
};
}
function assuranceNodeWrite(
draft: AssuranceNodeDraft,
provenance: Record<string, unknown> = { source: "risk-compliance-ui" }
): AssuranceNodeWrite {
return {
stable_id: draft.stableId.trim(),
kind: draft.kind,
label: draft.label.trim(),
description: draft.description.trim() || null,
state: draft.state,
owner_ref: draft.ownerRef.trim(),
scope_ref: draft.scopeRef.trim() || null,
governed_object_ref: draft.governedObjectRef.trim() || null,
valid_from: new Date(draft.validFrom).toISOString(),
valid_to: draft.validTo ? new Date(draft.validTo).toISOString() : null,
provenance,
legal_basis_refs: referenceLines(draft.legalBasisRefs),
policy_refs: referenceLines(draft.policyRefs),
evidence_refs: referenceLines(draft.evidenceRefs),
classification: draft.classification
};
}
function referenceLines(value: string) {
return Array.from(
new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))
);
}
function dateTimeLocalValue(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function formatToken(value: string) {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function EvidenceColumn({
title,
name,
+205 -2
View File
@@ -51,7 +51,8 @@
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout {
.risk-review-layout,
.risk-assurance-layout {
display: grid;
min-width: 0;
min-height: 0;
@@ -68,6 +69,185 @@
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
}
.risk-assurance-layout {
grid-template-rows: auto minmax(0, 1fr);
}
.risk-assurance-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
.risk-assurance-metrics > span {
display: grid;
gap: 3px;
border-right: var(--border-line);
color: var(--muted);
padding: 10px 12px;
font-size: 11px;
}
.risk-assurance-metrics > span:last-child {
border-right: 0;
}
.risk-assurance-metrics strong {
color: var(--text-strong);
font-size: 17px;
}
.risk-assurance-columns {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: minmax(300px, 0.75fr) minmax(480px, 1.55fr);
gap: 12px;
}
.risk-assurance-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.45fr);
gap: 8px;
border-bottom: var(--border-line);
padding: 8px 10px;
}
.risk-assurance-filter input,
.risk-assurance-filter select,
.risk-assurance-form input,
.risk-assurance-form select,
.risk-assurance-form textarea {
width: 100%;
box-sizing: border-box;
}
.risk-assurance-detail-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: auto;
}
.risk-assurance-detail-body > p {
margin: 0;
border-bottom: var(--border-line);
color: var(--text);
padding: 12px;
line-height: 1.5;
}
.risk-assurance-properties {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
border-bottom: var(--border-line);
}
.risk-assurance-properties > div {
min-width: 0;
border-right: var(--border-line);
border-bottom: var(--border-line);
padding: 10px 12px;
}
.risk-assurance-properties > div:nth-child(2n) {
border-right: 0;
}
.risk-assurance-properties dt {
margin-bottom: 4px;
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.risk-assurance-properties dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-size: 12px;
}
.risk-assurance-links-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
border-bottom: var(--border-line);
padding: 8px 12px;
}
.risk-assurance-links-header > div {
display: grid;
gap: 2px;
flex: 1;
}
.risk-assurance-links-header span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links {
min-height: 0;
overflow: auto;
}
.risk-assurance-links > button {
display: grid;
width: 100%;
grid-template-columns: minmax(110px, 0.45fr) minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
padding: 9px 12px;
text-align: left;
font: inherit;
cursor: pointer;
}
.risk-assurance-links > button:hover {
background: var(--sidebar-hover-bg);
}
.risk-assurance-links > button span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links > button strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.risk-assurance-truncated {
border-top: var(--border-line);
color: var(--warning);
padding: 9px 12px;
font-size: 11px;
}
.risk-assurance-form {
display: grid;
gap: 12px;
}
.risk-assurance-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.risk-panel {
display: flex;
min-width: 0;
@@ -333,11 +513,34 @@
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout {
.risk-review-layout,
.risk-assurance-layout,
.risk-assurance-columns {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-assurance-metrics > span:nth-child(2) {
border-right: 0;
}
.risk-assurance-metrics > span:nth-child(-n + 2) {
border-bottom: var(--border-line);
}
.risk-assurance-form-grid,
.risk-assurance-properties {
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-properties > div {
border-right: 0;
}
.risk-panel {
min-height: 340px;
}