feat: implement assurance graph and screening evidence
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Risk Compliance Codex Guide
|
# 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
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN Risk Compliance platform module seed.
|
This repository owns the GovOPlaN Risk Compliance platform module seed.
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ The first complete vertical is sanctions screening. Connectors acquires immutabl
|
|||||||
|
|
||||||
The module includes database migrations, tenant-isolated APIs, an operational
|
The module includes database migrations, tenant-isolated APIs, an operational
|
||||||
WebUI, append-only dispositions, time-bounded false-positive exceptions,
|
WebUI, append-only dispositions, time-bounded false-positive exceptions,
|
||||||
freshness reconciliation, explicit consumer gates, and audit events. Queue and
|
freshness reconciliation, explicit consumer gates, and audit events. It also
|
||||||
audit summaries deliberately retain only the minimum subject data needed for
|
provides a revisioned horizontal assurance graph from obligation through
|
||||||
the workflow.
|
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
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -27,6 +30,8 @@ the workflow.
|
|||||||
- immutable sanctions list catalogues
|
- immutable sanctions list catalogues
|
||||||
- sanctions screening and reviewer dispositions
|
- sanctions screening and reviewer dispositions
|
||||||
- stable screening evidence references and freshness gates
|
- stable screening evidence references and freshness gates
|
||||||
|
- effective-dated obligations, risks, controls, evidence, findings, corrective
|
||||||
|
measures, and effectiveness reviews
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ Risk and compliance workflows for data protection incidents, DPIAs, compliance c
|
|||||||
- version-pinned screening runs and candidate explanations
|
- version-pinned screening runs and candidate explanations
|
||||||
- reviewer dispositions, exceptions, and screening freshness
|
- reviewer dispositions, exceptions, and screening freshness
|
||||||
- block, review, and degraded screening-gate decisions
|
- 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
|
## Does Not Own
|
||||||
|
|
||||||
- immutable audit log storage
|
- immutable audit log storage
|
||||||
- records retention engine
|
- records retention engine
|
||||||
- inspection fieldwork
|
- inspection fieldwork
|
||||||
|
- domain-object lifecycle and corrective execution owned by the affected module
|
||||||
|
- policy rule evaluation or immutable audit-log storage
|
||||||
|
|
||||||
## Integration Candidates
|
## Integration Candidates
|
||||||
|
|
||||||
@@ -66,3 +71,49 @@ record.
|
|||||||
Fuzzy candidates always require review. A confirmed match blocks; a set of
|
Fuzzy candidates always require review. A confirmed match blocks; a set of
|
||||||
independently cleared false positives allows; source or execution uncertainty
|
independently cleared false positives allows; source or execution uncertainty
|
||||||
uses the consuming module's configured failure policy.
|
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__ = [
|
__all__ = [
|
||||||
|
"RiskAssuranceEdge",
|
||||||
|
"RiskAssuranceNode",
|
||||||
"RiskSanctionsAddress",
|
"RiskSanctionsAddress",
|
||||||
"RiskSanctionsAlias",
|
"RiskSanctionsAlias",
|
||||||
"RiskSanctionsDate",
|
"RiskSanctionsDate",
|
||||||
|
|||||||
@@ -26,9 +26,17 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.core.sanctions import (
|
from govoplan_core.core.sanctions import (
|
||||||
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
|
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.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_risk_compliance.backend.db.models import (
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
RiskSanctionsAddress,
|
RiskSanctionsAddress,
|
||||||
RiskSanctionsAlias,
|
RiskSanctionsAlias,
|
||||||
RiskSanctionsDate,
|
RiskSanctionsDate,
|
||||||
@@ -66,6 +74,8 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"connectors",
|
"connectors",
|
||||||
)
|
)
|
||||||
_PERSISTENT_MODELS = (
|
_PERSISTENT_MODELS = (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
RiskScreeningException,
|
RiskScreeningException,
|
||||||
RiskScreeningDisposition,
|
RiskScreeningDisposition,
|
||||||
RiskScreeningCandidate,
|
RiskScreeningCandidate,
|
||||||
@@ -79,6 +89,72 @@ _PERSISTENT_MODELS = (
|
|||||||
RiskSanctionsListSnapshot,
|
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(
|
def _permission(
|
||||||
scope: str,
|
scope: str,
|
||||||
@@ -140,9 +216,7 @@ ROLE_TEMPLATES = (
|
|||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="risk_compliance_manager",
|
slug="risk_compliance_manager",
|
||||||
name="Risk Compliance manager",
|
name="Risk Compliance manager",
|
||||||
description=(
|
description=("Manage compliance workflows and administer sanctions screening."),
|
||||||
"Manage compliance workflows and administer sanctions screening."
|
|
||||||
),
|
|
||||||
permissions=(
|
permissions=(
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
WRITE_SCOPE,
|
WRITE_SCOPE,
|
||||||
@@ -155,9 +229,7 @@ ROLE_TEMPLATES = (
|
|||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="risk_compliance_reviewer",
|
slug="risk_compliance_reviewer",
|
||||||
name="Risk Compliance reviewer",
|
name="Risk Compliance reviewer",
|
||||||
description=(
|
description=("Run screenings and independently review potential matches."),
|
||||||
"Run screenings and independently review potential matches."
|
|
||||||
),
|
|
||||||
permissions=(
|
permissions=(
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
SANCTIONS_READ_SCOPE,
|
SANCTIONS_READ_SCOPE,
|
||||||
@@ -188,13 +260,19 @@ def _sanctions_screening_provider(_context):
|
|||||||
return RiskComplianceSanctionsScreeningProvider()
|
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]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"risk_sanctions_list_snapshots": (
|
"risk_sanctions_list_snapshots": (
|
||||||
session.query(RiskSanctionsListSnapshot)
|
session.query(RiskSanctionsListSnapshot)
|
||||||
.filter(
|
.filter(RiskSanctionsListSnapshot.tenant_id == tenant_id)
|
||||||
RiskSanctionsListSnapshot.tenant_id == tenant_id
|
|
||||||
)
|
|
||||||
.count()
|
.count()
|
||||||
),
|
),
|
||||||
"risk_screening_runs": (
|
"risk_screening_runs": (
|
||||||
@@ -212,6 +290,14 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
)
|
)
|
||||||
.count()
|
.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 "
|
"Connectors may acquire source evidence, but Risk Compliance "
|
||||||
"owns immutable normalized sanctions lists, version-pinned "
|
"owns immutable normalized sanctions lists, version-pinned "
|
||||||
"screening, candidate review, and legal dispositions. Fuzzy "
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -242,8 +332,7 @@ DOCUMENTATION = (
|
|||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Repository domain boundary",
|
label="Repository domain boundary",
|
||||||
href=(
|
href=(
|
||||||
"govoplan-risk-compliance/"
|
"govoplan-risk-compliance/docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md"
|
||||||
"docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md"
|
|
||||||
),
|
),
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
@@ -260,6 +349,20 @@ DOCUMENTATION = (
|
|||||||
"Queue and audit summaries contain stable references and "
|
"Queue and audit summaries contain stable references and "
|
||||||
"minimal subject data."
|
"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,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider),
|
||||||
_sanctions_screening_provider
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="risk_compliance.assurance",
|
||||||
|
factory=_assurance_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
package_name="@govoplan/risk-compliance-webui",
|
package_name="@govoplan/risk-compliance-webui",
|
||||||
@@ -303,7 +410,7 @@ manifest = ModuleManifest(
|
|||||||
FrontendRoute(
|
FrontendRoute(
|
||||||
path="/risk-compliance",
|
path="/risk-compliance",
|
||||||
component="RiskCompliancePage",
|
component="RiskCompliancePage",
|
||||||
required_any=(SANCTIONS_READ_SCOPE,),
|
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
|
||||||
order=115,
|
order=115,
|
||||||
surface_id="risk_compliance.workspace",
|
surface_id="risk_compliance.workspace",
|
||||||
),
|
),
|
||||||
@@ -313,7 +420,7 @@ manifest = ModuleManifest(
|
|||||||
path="/risk-compliance",
|
path="/risk-compliance",
|
||||||
label="Risk Compliance",
|
label="Risk Compliance",
|
||||||
icon="shield-check",
|
icon="shield-check",
|
||||||
required_any=(SANCTIONS_READ_SCOPE,),
|
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
|
||||||
order=115,
|
order=115,
|
||||||
surface_id="risk_compliance.navigation",
|
surface_id="risk_compliance.navigation",
|
||||||
),
|
),
|
||||||
@@ -340,15 +447,21 @@ manifest = ModuleManifest(
|
|||||||
label="Sanctions review queue",
|
label="Sanctions review queue",
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.assurance.graph",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Assurance graph",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
architecture=ARCHITECTURE,
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
Path(__file__).with_name("migrations") / "versions"
|
|
||||||
),
|
|
||||||
migration_after=("connectors",),
|
migration_after=("connectors",),
|
||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
@@ -356,8 +469,9 @@ manifest = ModuleManifest(
|
|||||||
label="Risk Compliance",
|
label="Risk Compliance",
|
||||||
),
|
),
|
||||||
retirement_notes=(
|
retirement_notes=(
|
||||||
"Destructive retirement removes immutable sanctions list, "
|
"Destructive retirement removes immutable assurance graph, "
|
||||||
"screening, and review evidence after a database snapshot."
|
"sanctions list, screening, and review evidence after a database "
|
||||||
|
"snapshot."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
|
|||||||
+180
@@ -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.auth import ApiPrincipal, get_api_principal
|
||||||
from govoplan_core.core.sanctions import sanctions_snapshot_provider
|
from govoplan_core.core.sanctions import sanctions_snapshot_provider
|
||||||
from govoplan_core.db.session import get_session
|
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 (
|
from govoplan_risk_compliance.backend.review import (
|
||||||
DispositionInput,
|
DispositionInput,
|
||||||
get_candidate,
|
get_candidate,
|
||||||
@@ -23,6 +40,16 @@ from govoplan_risk_compliance.backend.sanctions_catalog import (
|
|||||||
list_list_snapshots,
|
list_list_snapshots,
|
||||||
)
|
)
|
||||||
from govoplan_risk_compliance.backend.schemas import (
|
from govoplan_risk_compliance.backend.schemas import (
|
||||||
|
AssuranceEdgeListResponse,
|
||||||
|
AssuranceEdgeResponse,
|
||||||
|
AssuranceEdgeRevisionRequest,
|
||||||
|
AssuranceEdgeWrite,
|
||||||
|
AssuranceGraphResponse,
|
||||||
|
AssuranceNodeListResponse,
|
||||||
|
AssuranceNodeResponse,
|
||||||
|
AssuranceNodeRevisionRequest,
|
||||||
|
AssuranceNodeWrite,
|
||||||
|
AssuranceSummaryResponse,
|
||||||
CandidateDetailResponse,
|
CandidateDetailResponse,
|
||||||
CandidateResponse,
|
CandidateResponse,
|
||||||
ConnectorSnapshotListResponse,
|
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(
|
@router.get(
|
||||||
"/sanctions/source-snapshots",
|
"/sanctions/source-snapshots",
|
||||||
response_model=ConnectorSnapshotListResponse,
|
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"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -352,7 +352,124 @@ class DispositionListResponse(BaseModel):
|
|||||||
dispositions: list[DispositionResponse]
|
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__ = [
|
__all__ = [
|
||||||
|
"AssuranceEdgeListResponse",
|
||||||
|
"AssuranceEdgeResponse",
|
||||||
|
"AssuranceEdgeRevisionRequest",
|
||||||
|
"AssuranceEdgeWrite",
|
||||||
|
"AssuranceGraphResponse",
|
||||||
|
"AssuranceNodeListResponse",
|
||||||
|
"AssuranceNodeResponse",
|
||||||
|
"AssuranceNodeRevisionRequest",
|
||||||
|
"AssuranceNodeWrite",
|
||||||
|
"AssuranceSummaryResponse",
|
||||||
"CandidateDetailResponse",
|
"CandidateDetailResponse",
|
||||||
"CandidateResponse",
|
"CandidateResponse",
|
||||||
"ConnectorSnapshotListResponse",
|
"ConnectorSnapshotListResponse",
|
||||||
|
|||||||
@@ -152,11 +152,12 @@ def run_screening(
|
|||||||
raise RiskSanctionsConflictError(
|
raise RiskSanctionsConflictError(
|
||||||
"Idempotency key was already used for another screening request."
|
"Idempotency key was already used for another screening request."
|
||||||
)
|
)
|
||||||
return get_screening_run(
|
return _screening_result(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
run_id=existing.id,
|
run_id=existing.id,
|
||||||
), False
|
created=False,
|
||||||
|
)
|
||||||
|
|
||||||
list_snapshot = _visible_list_snapshot(
|
list_snapshot = _visible_list_snapshot(
|
||||||
session,
|
session,
|
||||||
@@ -207,11 +208,12 @@ def run_screening(
|
|||||||
run.outcome = list_state
|
run.outcome = list_state
|
||||||
run.completed_at = utcnow()
|
run.completed_at = utcnow()
|
||||||
session.flush()
|
session.flush()
|
||||||
return get_screening_run(
|
return _screening_result(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
), True
|
created=True,
|
||||||
|
)
|
||||||
|
|
||||||
entries = tuple(
|
entries = tuple(
|
||||||
session.scalars(
|
session.scalars(
|
||||||
@@ -235,11 +237,12 @@ def run_screening(
|
|||||||
}
|
}
|
||||||
run.completed_at = utcnow()
|
run.completed_at = utcnow()
|
||||||
session.flush()
|
session.flush()
|
||||||
return get_screening_run(
|
return _screening_result(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
), True
|
created=True,
|
||||||
|
)
|
||||||
|
|
||||||
candidates = sorted(
|
candidates = sorted(
|
||||||
(
|
(
|
||||||
@@ -297,11 +300,12 @@ def run_screening(
|
|||||||
run.outcome = "potential" if candidates else "clear"
|
run.outcome = "potential" if candidates else "clear"
|
||||||
run.completed_at = utcnow()
|
run.completed_at = utcnow()
|
||||||
session.flush()
|
session.flush()
|
||||||
return get_screening_run(
|
return _screening_result(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
), True
|
created=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def screening_evidence_ref(run_id: str) -> str:
|
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}"
|
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(
|
def assess_screening_freshness(
|
||||||
session: Session,
|
session: Session,
|
||||||
principal: ApiPrincipal,
|
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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -48,6 +48,11 @@ class ManifestTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIsNotNone(manifest.frontend)
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||||
|
self.assertIn(
|
||||||
|
"governance_overlay",
|
||||||
|
manifest.architecture.supported_authority_modes,
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"connectors.sanctions_snapshots",
|
"connectors.sanctions_snapshots",
|
||||||
manifest.requires_interfaces[0].name,
|
manifest.requires_interfaces[0].name,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class RiskComplianceMigrationTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"a8b9c0d1e2f3",
|
"b9c0d1e2f3a4",
|
||||||
set(
|
set(
|
||||||
MigrationContext.configure(
|
MigrationContext.configure(
|
||||||
connection
|
connection
|
||||||
@@ -49,6 +49,8 @@ class RiskComplianceMigrationTests(unittest.TestCase):
|
|||||||
"risk_screening_exceptions",
|
"risk_screening_exceptions",
|
||||||
tables,
|
tables,
|
||||||
)
|
)
|
||||||
|
self.assertIn("risk_assurance_nodes", tables)
|
||||||
|
self.assertIn("risk_assurance_edges", tables)
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from govoplan_core.core.sanctions import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base, utcnow
|
from govoplan_core.db.base import Base, utcnow
|
||||||
from govoplan_risk_compliance.backend.db.models import (
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
RiskSanctionsAddress,
|
RiskSanctionsAddress,
|
||||||
RiskSanctionsAlias,
|
RiskSanctionsAlias,
|
||||||
RiskSanctionsDate,
|
RiskSanctionsDate,
|
||||||
@@ -104,6 +106,8 @@ UN_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
TABLES = (
|
TABLES = (
|
||||||
|
RiskAssuranceNode.__table__,
|
||||||
|
RiskAssuranceEdge.__table__,
|
||||||
RiskSanctionsListSnapshot.__table__,
|
RiskSanctionsListSnapshot.__table__,
|
||||||
RiskSanctionsEntry.__table__,
|
RiskSanctionsEntry.__table__,
|
||||||
RiskSanctionsAlias.__table__,
|
RiskSanctionsAlias.__table__,
|
||||||
@@ -254,6 +258,27 @@ class SanctionsScreeningTests(unittest.TestCase):
|
|||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
item.candidates[0].evidence[0]["auto_confirmed"]
|
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(
|
replay, replay_created = run_screening(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -266,7 +291,9 @@ class SanctionsScreeningTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertFalse(replay_created)
|
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):
|
with self.assertRaises(RiskSanctionsConflictError):
|
||||||
run_screening(
|
run_screening(
|
||||||
|
|||||||
@@ -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(
|
export async function listConnectorSnapshots(
|
||||||
settings: ApiSettings
|
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 {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Database,
|
Database,
|
||||||
|
Network,
|
||||||
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Scale,
|
Scale,
|
||||||
Upload
|
Upload
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type FormEvent
|
type FormEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -28,12 +32,23 @@ import {
|
|||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
createDisposition,
|
createDisposition,
|
||||||
|
getAssuranceGraph,
|
||||||
|
getAssuranceSummary,
|
||||||
getCandidate,
|
getCandidate,
|
||||||
importListSnapshot,
|
importListSnapshot,
|
||||||
listConnectorSnapshots,
|
listConnectorSnapshots,
|
||||||
|
listAssuranceNodes,
|
||||||
listListSnapshots,
|
listListSnapshots,
|
||||||
listReviewQueue,
|
listReviewQueue,
|
||||||
runScreening,
|
runScreening,
|
||||||
|
saveAssuranceEdge,
|
||||||
|
saveAssuranceNode,
|
||||||
|
type AssuranceEdge,
|
||||||
|
type AssuranceEdgeWrite,
|
||||||
|
type AssuranceNode,
|
||||||
|
type AssuranceNodeKind,
|
||||||
|
type AssuranceNodeWrite,
|
||||||
|
type AssuranceSummary,
|
||||||
type CandidateDetail,
|
type CandidateDetail,
|
||||||
type ConnectorSnapshot,
|
type ConnectorSnapshot,
|
||||||
type ListSnapshot,
|
type ListSnapshot,
|
||||||
@@ -42,13 +57,18 @@ import {
|
|||||||
} from "../../api/riskCompliance";
|
} from "../../api/riskCompliance";
|
||||||
|
|
||||||
|
|
||||||
type ViewMode = "sources" | "screen" | "review";
|
type ViewMode = "sources" | "screen" | "review" | "assurance";
|
||||||
|
|
||||||
export default function RiskCompliancePage({
|
export default function RiskCompliancePage({
|
||||||
settings,
|
settings,
|
||||||
auth
|
auth
|
||||||
}: PlatformRouteContext) {
|
}: 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<
|
const [sourceSnapshots, setSourceSnapshots] = useState<
|
||||||
ConnectorSnapshot[]
|
ConnectorSnapshot[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -58,6 +78,17 @@ export default function RiskCompliancePage({
|
|||||||
const [selectedCandidateId, setSelectedCandidateId] = useState("");
|
const [selectedCandidateId, setSelectedCandidateId] = useState("");
|
||||||
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
|
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
|
||||||
const [run, setRun] = useState<ScreeningRun | 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 [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -74,34 +105,71 @@ export default function RiskCompliancePage({
|
|||||||
auth,
|
auth,
|
||||||
"risk_compliance:sanctions:review"
|
"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 () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [sources, lists, reviewQueue] = await Promise.all([
|
const [sources, lists, reviewQueue, assurance] = await Promise.all([
|
||||||
listConnectorSnapshots(settings),
|
listConnectorSnapshots(settings),
|
||||||
listListSnapshots(settings),
|
listListSnapshots(settings),
|
||||||
canReview
|
canReview
|
||||||
? listReviewQueue(settings)
|
? 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);
|
setSourcesAvailable(sources.available);
|
||||||
setSourceSnapshots(sources.snapshots);
|
setSourceSnapshots(sources.snapshots);
|
||||||
setListSnapshots(lists.snapshots);
|
setListSnapshots(lists.snapshots);
|
||||||
setQueue(reviewQueue.candidates);
|
setQueue(reviewQueue.candidates);
|
||||||
|
setAssuranceNodes(assurance.nodes);
|
||||||
|
setAssuranceSummary(assurance.summary);
|
||||||
setSelectedCandidateId((current) =>
|
setSelectedCandidateId((current) =>
|
||||||
current &&
|
current &&
|
||||||
reviewQueue.candidates.some((item) => item.id === current)
|
reviewQueue.candidates.some((item) => item.id === current)
|
||||||
? current
|
? current
|
||||||
: reviewQueue.candidates[0]?.id ?? ""
|
: reviewQueue.candidates[0]?.id ?? ""
|
||||||
);
|
);
|
||||||
|
setSelectedAssuranceId((current) =>
|
||||||
|
current || assurance.nodes[0]?.stable_id || ""
|
||||||
|
);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(errorMessage(reason));
|
setError(errorMessage(reason));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canReview, settings]);
|
}, [canReadAssurance, canReview, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -125,6 +193,24 @@ export default function RiskCompliancePage({
|
|||||||
};
|
};
|
||||||
}, [selectedCandidateId, settings]);
|
}, [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) {
|
async function importSnapshot(item: ConnectorSnapshot) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -182,6 +268,16 @@ export default function RiskCompliancePage({
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
disabled: !canReview
|
disabled: !canReview
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assurance",
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<Network size={15} />
|
||||||
|
Assurance
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
disabled: !canReadAssurance
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -250,6 +346,22 @@ export default function RiskCompliancePage({
|
|||||||
settings={settings}
|
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>
|
</div>
|
||||||
</main>
|
</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({
|
function EvidenceColumn({
|
||||||
title,
|
title,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
|
|
||||||
.risk-source-layout,
|
.risk-source-layout,
|
||||||
.risk-screen-layout,
|
.risk-screen-layout,
|
||||||
.risk-review-layout {
|
.risk-review-layout,
|
||||||
|
.risk-assurance-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -68,6 +69,185 @@
|
|||||||
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
|
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 {
|
.risk-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -333,11 +513,34 @@
|
|||||||
|
|
||||||
.risk-source-layout,
|
.risk-source-layout,
|
||||||
.risk-screen-layout,
|
.risk-screen-layout,
|
||||||
.risk-review-layout {
|
.risk-review-layout,
|
||||||
|
.risk-assurance-layout,
|
||||||
|
.risk-assurance-columns {
|
||||||
height: auto;
|
height: auto;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
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 {
|
.risk-panel {
|
||||||
min-height: 340px;
|
min-height: 340px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user