10 Commits
Author SHA1 Message Date
zemion 692ffe80bb Release govoplan-risk-compliance v0.1.21: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 10s
2026-09-08 01:32:52 +02:00
zemion e898c853e5 fix(webui): bind compliance disposition to help
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 11:36:45 +02:00
zemion 05e5caa447 docs(risk-compliance): add German assurance guidance
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 01:54:20 +02:00
zemion 37d7120023 feat(risk-compliance): add governed DSAR coverage 2026-08-21 13:11:55 +02:00
zemion bd88b623a6 feat: align risk compliance with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion dabc568429 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 504255a0bd Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 05e1e246ca Adopt shared WebUI layout primitives 2026-08-18 10:42:54 +02:00
zemion 8a50529f71 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:46 +02:00
zemion a633100a97 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:10 +02:00
10 changed files with 1637 additions and 683 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/risk-compliance-webui", "name": "@govoplan/risk-compliance-webui",
"version": "0.1.16", "version": "0.1.21",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.16", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-risk-compliance" name = "govoplan-risk-compliance"
version = "0.1.16" version = "0.1.21"
description = "GovOPlaN Risk Compliance platform module seed." description = "GovOPlaN Risk Compliance platform module seed."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -12,8 +12,8 @@ license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"govoplan-core>=0.1.16", "govoplan-core>=0.1.45",
"govoplan-access>=0.1.16", "govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,695 @@
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskSanctionsListSnapshot,
RiskScreeningCandidate,
RiskScreeningDisposition,
RiskScreeningException,
RiskScreeningRun,
RiskScreeningSubjectSnapshot,
)
RISK_COMPLIANCE_DSAR_CAPABILITY = dsar_capability_name("risk_compliance")
_MAX_RECORDS = 5_000
_MAX_SUBJECT_ITEMS = 100
_MAX_SUBJECT_BYTES = 256 * 1024
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str | None
membership_id: str | None
subject_ref: str | None
screening_id: str | None
assurance_node_id: str | None
assurance_edge_id: str | None
@property
def actor_ids(self) -> tuple[str, ...]:
return tuple(
dict.fromkeys(
value for value in (self.account_id, self.membership_id) if value
)
)
@property
def narrowed(self) -> bool:
return bool(
self.screening_id or self.assurance_node_id or self.assurance_edge_id
)
class RiskComplianceDsarProvider:
provider_id = "risk_compliance"
module_id = "risk_compliance"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if selectors.subject_ref and (not selectors.narrowed or selectors.screening_id):
query = (
db.query(RiskScreeningRun, RiskScreeningSubjectSnapshot)
.join(
RiskScreeningSubjectSnapshot,
RiskScreeningSubjectSnapshot.id
== RiskScreeningRun.subject_snapshot_id,
)
.filter(
RiskScreeningRun.tenant_id == tenant_id,
RiskScreeningSubjectSnapshot.tenant_id == tenant_id,
RiskScreeningSubjectSnapshot.subject_ref == selectors.subject_ref,
)
)
if selectors.screening_id:
query = query.filter(RiskScreeningRun.id == selectors.screening_id)
records.extend(
_subject_screening_record(run, snapshot)
for run, snapshot in _limited(
query,
RiskScreeningRun.started_at,
RiskScreeningRun.id,
label="subject screening",
)
)
actor_ids = selectors.actor_ids
if actor_ids and (not selectors.narrowed or selectors.screening_id):
run_query = (
db.query(RiskScreeningRun, RiskScreeningSubjectSnapshot)
.join(
RiskScreeningSubjectSnapshot,
RiskScreeningSubjectSnapshot.id
== RiskScreeningRun.subject_snapshot_id,
)
.filter(
RiskScreeningRun.tenant_id == tenant_id,
RiskScreeningSubjectSnapshot.tenant_id == tenant_id,
or_(
RiskScreeningRun.created_by.in_(actor_ids),
RiskScreeningSubjectSnapshot.submitted_by.in_(actor_ids),
),
)
)
if selectors.screening_id:
run_query = run_query.filter(
RiskScreeningRun.id == selectors.screening_id
)
records.extend(
_screening_actor_record(run, snapshot, actor_ids)
for run, snapshot in _limited(
run_query,
RiskScreeningRun.started_at,
RiskScreeningRun.id,
label="screening actor attribution",
)
)
disposition_query = db.query(RiskScreeningDisposition).filter(
RiskScreeningDisposition.tenant_id == tenant_id,
or_(
RiskScreeningDisposition.actor_account_id.in_(actor_ids),
RiskScreeningDisposition.actor_membership_id.in_(actor_ids),
),
)
if selectors.screening_id:
disposition_query = (
db.query(RiskScreeningDisposition)
.join(
RiskScreeningCandidate,
RiskScreeningCandidate.id
== RiskScreeningDisposition.candidate_id,
)
.filter(
RiskScreeningDisposition.tenant_id == tenant_id,
RiskScreeningCandidate.run_id == selectors.screening_id,
or_(
RiskScreeningDisposition.actor_account_id.in_(actor_ids),
RiskScreeningDisposition.actor_membership_id.in_(actor_ids),
),
)
)
records.extend(
_disposition_actor_record(row)
for row in _limited(
disposition_query,
RiskScreeningDisposition.created_at,
RiskScreeningDisposition.id,
label="disposition attribution",
)
)
if actor_ids and not selectors.narrowed:
records.extend(self._unnarrowed_actor_records(db, tenant_id, actor_ids))
if actor_ids and selectors.assurance_node_id:
query = db.query(RiskAssuranceNode).filter(
RiskAssuranceNode.tenant_id == tenant_id,
RiskAssuranceNode.id == selectors.assurance_node_id,
RiskAssuranceNode.created_by.in_(actor_ids),
)
records.extend(
_assurance_node_actor_record(row)
for row in _limited(
query,
RiskAssuranceNode.recorded_at,
RiskAssuranceNode.id,
label="assurance-node attribution",
)
)
if actor_ids and selectors.assurance_edge_id:
query = db.query(RiskAssuranceEdge).filter(
RiskAssuranceEdge.tenant_id == tenant_id,
RiskAssuranceEdge.id == selectors.assurance_edge_id,
RiskAssuranceEdge.created_by.in_(actor_ids),
)
records.extend(
_assurance_edge_actor_record(row)
for row in _limited(
query,
RiskAssuranceEdge.recorded_at,
RiskAssuranceEdge.id,
label="assurance-edge attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Risk Compliance DSAR result limit exceeded; narrow selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
@staticmethod
def _unnarrowed_actor_records(
db: Session,
tenant_id: str,
actor_ids: tuple[str, ...],
) -> list[DsarRecordRef]:
records: list[DsarRecordRef] = []
imports = db.query(RiskSanctionsListSnapshot).filter(
RiskSanctionsListSnapshot.tenant_id == tenant_id,
RiskSanctionsListSnapshot.imported_by.in_(actor_ids),
)
records.extend(
_snapshot_import_actor_record(row)
for row in _limited(
imports,
RiskSanctionsListSnapshot.imported_at,
RiskSanctionsListSnapshot.id,
label="snapshot-import attribution",
)
)
exceptions = db.query(RiskScreeningException).filter(
RiskScreeningException.tenant_id == tenant_id,
RiskScreeningException.created_by.in_(actor_ids),
)
records.extend(
_exception_actor_record(row)
for row in _limited(
exceptions,
RiskScreeningException.created_at,
RiskScreeningException.id,
label="exception attribution",
)
)
nodes = db.query(RiskAssuranceNode).filter(
RiskAssuranceNode.tenant_id == tenant_id,
RiskAssuranceNode.created_by.in_(actor_ids),
)
records.extend(
_assurance_node_actor_record(row)
for row in _limited(
nodes,
RiskAssuranceNode.recorded_at,
RiskAssuranceNode.id,
label="assurance-node attribution",
)
)
edges = db.query(RiskAssuranceEdge).filter(
RiskAssuranceEdge.tenant_id == tenant_id,
RiskAssuranceEdge.created_by.in_(actor_ids),
)
records.extend(
_assurance_edge_actor_record(row)
for row in _limited(
edges,
RiskAssuranceEdge.recorded_at,
RiskAssuranceEdge.id,
label="assurance-edge attribution",
)
)
return records
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Risk Compliance DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"risk_compliance:retain:{record.resource_type}:"
f"{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=(
record.retention_reason
or "Risk and compliance evidence remains immutable."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Risk Compliance DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Risk Compliance DSAR publishes retain actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Risk and compliance evidence remains unchanged under its "
"legal, audit, and accountability obligations."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("risk_compliance.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("risk_compliance.membership"),
references.get("tenancy.membership"),
),
"subject_ref": _coalesce(
references.get("risk_compliance.subject"),
references.get("risk_compliance.subject_ref"),
),
"screening_id": _coalesce(
references.get("risk_compliance.screening"),
references.get("risk_compliance.screening_id"),
),
"assurance_node_id": _coalesce(
references.get("risk_compliance.assurance_node"),
references.get("risk_compliance.assurance_node_id"),
),
"assurance_edge_id": _coalesce(
references.get("risk_compliance.assurance_edge"),
references.get("risk_compliance.assurance_edge_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
selectors = _SubjectSelectors(
account_id=_optional_string(values["account_id"]),
membership_id=_optional_string(values["membership_id"]),
subject_ref=_optional_string(values["subject_ref"]),
screening_id=_optional_string(values["screening_id"]),
assurance_node_id=_optional_string(values["assurance_node_id"]),
assurance_edge_id=_optional_string(values["assurance_edge_id"]),
)
if not selectors.actor_ids and not selectors.subject_ref:
return None
return selectors
def _subject_screening_record(
run: RiskScreeningRun, snapshot: RiskScreeningSubjectSnapshot
) -> DsarRecordRef:
data = {
"screening_id": run.id,
"subject_snapshot_id": snapshot.id,
"subject_ref": snapshot.subject_ref,
"subject_type": snapshot.subject_type,
"primary_name": (snapshot.primary_name or "")[:1_000] or None,
"aliases": _string_list(snapshot.aliases, 1_000),
"identifiers": _mapping_list(
snapshot.identifiers,
allowed=("type", "value"),
limits={"type": 100, "value": 1_000},
),
"dates": _string_list(snapshot.dates, 100),
"addresses": _mapping_list(
snapshot.addresses,
allowed=("street", "city", "region", "postal_code", "country"),
limits={
"street": 1_000,
"city": 500,
"region": 500,
"postal_code": 100,
"country": 255,
},
),
"status": run.status,
"outcome": run.outcome,
"candidate_count": run.candidate_count,
"started_at": _iso(run.started_at),
"completed_at": _iso(run.completed_at),
}
_bounded_json(data)
return _record(
resource_type="screening_subject_submission",
resource_id=run.id,
category="sanctions_screening_subject_data",
title="Sanctions screening subject submission",
data=data,
observed_at=run.completed_at or run.started_at,
retention_reason=(
"Version-pinned screening inputs and outcomes are retained as legal and "
"compliance evidence."
),
)
def _screening_actor_record(
run: RiskScreeningRun,
snapshot: RiskScreeningSubjectSnapshot,
actor_ids: tuple[str, ...],
) -> DsarRecordRef:
activities = []
if run.created_by in actor_ids:
activities.append("created_screening")
if snapshot.submitted_by in actor_ids:
activities.append("submitted_screening_subject")
return _record(
resource_type="screening_actor_attribution",
resource_id=run.id,
category="risk_compliance_actor_attribution",
title="Screening actor attribution",
data={
"screening_id": run.id,
"status": run.status,
"outcome": run.outcome,
"candidate_count": run.candidate_count,
"activities": activities,
"started_at": _iso(run.started_at),
"completed_at": _iso(run.completed_at),
},
observed_at=run.completed_at or run.started_at,
)
def _snapshot_import_actor_record(row: RiskSanctionsListSnapshot) -> DsarRecordRef:
return _record(
resource_type="snapshot_import_actor_attribution",
resource_id=row.id,
category="risk_compliance_actor_attribution",
title="Sanctions snapshot import attribution",
data={
"snapshot_id": row.id,
"provider_id": row.provider_id,
"source_id": row.source_id,
"source_version": row.source_version,
"status": row.status,
"entry_count": row.entry_count,
"activity": "imported_sanctions_snapshot",
"imported_at": _iso(row.imported_at),
},
observed_at=row.imported_at,
)
def _disposition_actor_record(row: RiskScreeningDisposition) -> DsarRecordRef:
return _record(
resource_type="disposition_actor_attribution",
resource_id=row.id,
category="risk_compliance_actor_attribution",
title="Screening disposition actor attribution",
data={
"disposition_id": row.id,
"candidate_id": row.candidate_id,
"decision": row.decision,
"scope": row.scope,
"separation_status": row.separation_status,
"expires_at": _iso(row.expires_at),
"review_at": _iso(row.review_at),
"activity": "recorded_screening_disposition",
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
)
def _exception_actor_record(row: RiskScreeningException) -> DsarRecordRef:
return _record(
resource_type="exception_actor_attribution",
resource_id=row.id,
category="risk_compliance_actor_attribution",
title="Screening exception actor attribution",
data={
"exception_id": row.id,
"scope": row.scope,
"status": row.status,
"starts_at": _iso(row.starts_at),
"expires_at": _iso(row.expires_at),
"review_at": _iso(row.review_at),
"activity": "created_screening_exception",
},
observed_at=row.created_at,
)
def _assurance_node_actor_record(row: RiskAssuranceNode) -> DsarRecordRef:
return _record(
resource_type="assurance_node_actor_attribution",
resource_id=row.id,
category="risk_compliance_actor_attribution",
title="Assurance-object actor attribution",
data={
"assurance_node_id": row.id,
"stable_id": row.stable_id,
"kind": row.kind,
"revision": row.revision,
"state": row.state,
"valid_from": _iso(row.valid_from),
"valid_to": _iso(row.valid_to),
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "created_assurance_object_revision",
},
observed_at=row.recorded_at,
)
def _assurance_edge_actor_record(row: RiskAssuranceEdge) -> DsarRecordRef:
return _record(
resource_type="assurance_edge_actor_attribution",
resource_id=row.id,
category="risk_compliance_actor_attribution",
title="Assurance-relation actor attribution",
data={
"assurance_edge_id": row.id,
"stable_id": row.stable_id,
"revision": row.revision,
"relation": row.relation,
"state": row.state,
"valid_from": _iso(row.valid_from),
"valid_to": _iso(row.valid_to),
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "created_assurance_relation_revision",
},
observed_at=row.recorded_at,
)
def _record(
*,
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
observed_at: datetime | None,
retention_reason: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="risk_compliance",
module_id="risk_compliance",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
retention_reason
or "Risk and compliance attribution is retained for legal, audit, and accountability evidence."
),
)
def _string_list(value: object, item_limit: int) -> list[str]:
if not isinstance(value, list) or len(value) > _MAX_SUBJECT_ITEMS:
raise ValueError("Risk Compliance DSAR subject list exceeds its bound.")
return [str(item)[:item_limit] for item in value]
def _mapping_list(
value: object,
*,
allowed: tuple[str, ...],
limits: Mapping[str, int],
) -> list[dict[str, str]]:
if not isinstance(value, list) or len(value) > _MAX_SUBJECT_ITEMS:
raise ValueError("Risk Compliance DSAR subject mapping list exceeds its bound.")
result: list[dict[str, str]] = []
for item in value:
if not isinstance(item, Mapping):
raise ValueError("Risk Compliance DSAR subject mapping is invalid.")
result.append(
{
key: str(item[key])[: limits[key]]
for key in allowed
if item.get(key) is not None
}
)
return result
def _bounded_json(value: object) -> None:
try:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError("Risk Compliance DSAR subject data is invalid.") from exc
if len(encoded) > _MAX_SUBJECT_BYTES:
raise ValueError("Risk Compliance DSAR subject data exceeds its byte bound.")
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(
f"Risk Compliance DSAR {label} limit exceeded; narrow selectors."
)
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Risk Compliance DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"screening_subject_submission",
"screening_actor_attribution",
"snapshot_import_actor_attribution",
"disposition_actor_attribution",
"exception_actor_attribution",
"assurance_node_actor_attribution",
"assurance_edge_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "risk_compliance" or record.module_id != "risk_compliance":
raise ValueError("Risk Compliance DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Risk Compliance DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "risk_compliance" or action.module_id != "risk_compliance":
raise ValueError(
"Risk Compliance DSAR cannot execute a foreign provider action."
)
if not action.action_id.startswith("risk_compliance:retain:"):
raise ValueError("Risk Compliance DSAR action identity is invalid.")
__all__ = ["RISK_COMPLIANCE_DSAR_CAPABILITY", "RiskComplianceDsarProvider"]
+246 -31
View File
@@ -11,6 +11,8 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -21,6 +23,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.sanctions import ( from govoplan_core.core.sanctions import (
@@ -49,6 +52,10 @@ from govoplan_risk_compliance.backend.db.models import (
RiskScreeningRun, RiskScreeningRun,
RiskScreeningSubjectSnapshot, RiskScreeningSubjectSnapshot,
) )
from govoplan_risk_compliance.backend.dsar_provider import (
RISK_COMPLIANCE_DSAR_CAPABILITY,
RiskComplianceDsarProvider,
)
from govoplan_risk_compliance.backend.permissions import ( from govoplan_risk_compliance.backend.permissions import (
ADMIN_SCOPE, ADMIN_SCOPE,
READ_SCOPE, READ_SCOPE,
@@ -62,7 +69,7 @@ from govoplan_risk_compliance.backend.permissions import (
MODULE_ID = "risk_compliance" MODULE_ID = "risk_compliance"
MODULE_NAME = "Risk Compliance" MODULE_NAME = "Risk Compliance"
MODULE_VERSION = "0.1.16" MODULE_VERSION = "0.1.21"
OPTIONAL_DEPENDENCIES = ( OPTIONAL_DEPENDENCIES = (
"audit", "audit",
"policy", "policy",
@@ -268,6 +275,10 @@ def _assurance_search_source(context):
return create_risk_assurance_search_source(context) return create_risk_assurance_search_source(context)
def _dsar_provider(_context) -> RiskComplianceDsarProvider:
return RiskComplianceDsarProvider()
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": (
@@ -304,10 +315,10 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic( DocumentationTopic(
id=f"{MODULE_ID}.module-boundary", id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary", title="Screen sanctions and manage assurance evidence",
summary=( summary=(
"Risk and compliance workflows own legal evaluation, immutable " "Run version-pinned sanctions screening and connect risks, controls, "
"screening evidence, review, and dispositions." "evidence, findings, and corrective measures without automating legal conclusions."
), ),
body=( body=(
"Connectors may acquire source evidence, but Risk Compliance " "Connectors may acquire source evidence, but Risk Compliance "
@@ -326,6 +337,9 @@ DOCUMENTATION = (
"module_admin", "module_admin",
"compliance_reviewer", "compliance_reviewer",
), ),
conditions=(
DocumentationCondition(any_scopes=(READ_SCOPE, SANCTIONS_READ_SCOPE)),
),
order=100, order=100,
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
@@ -338,38 +352,43 @@ DOCUMENTATION = (
), ),
DocumentationLink( DocumentationLink(
label="Interface pattern migration", label="Interface pattern migration",
href=( href=("govoplan-risk-compliance/docs/INTERFACE_PATTERN_MIGRATION.md"),
"govoplan-risk-compliance/docs/INTERFACE_PATTERN_MIGRATION.md"
),
kind="repository", kind="repository",
), ),
), ),
metadata={ metadata={
"domain_objects": [ "kind": "workflow",
"sanctions list snapshots", "purpose": (
"screening runs", "Produce reproducible screening and assurance evidence while keeping legal review explicit and human-accountable."
"candidate evidence",
"review dispositions",
"time-bounded exceptions",
],
"privacy": (
"Queue and audit summaries contain stable references and "
"minimal subject data."
), ),
"assurance_domain_model": [ "prerequisites": [
"obligation", "The actor can read the relevant assurance or sanctions area; import, screening, review, and editing use dedicated scopes.",
"governed object reference", "A connector-provided source snapshot is available before sanctions-list import.",
"risk", "The minimum necessary screening subject data and an exact governed subject reference are available.",
"control", ],
"evidence", "steps": [
"finding", "Import connector evidence into an immutable normalized sanctions-list snapshot.",
"corrective measure", "Run screening against one pinned snapshot using only the required subject data.",
"effectiveness review", "Review every fuzzy candidate and record an evidence-backed disposition or time-bounded exception.",
"Create or revise assurance objects for obligations, risks, controls, evidence, findings, and corrective measures.",
"Connect assurance revisions through typed governed relationships and review effectiveness over time.",
],
"fields": {
"source_snapshot": "An immutable normalized list revision with connector provenance and content fingerprint.",
"screening_run": "A version-pinned comparison of minimum subject data against one source snapshot.",
"candidate": "Potential matching evidence that requires human review and is never a confirmed match by itself.",
"disposition": "An append-only legal review outcome with reviewer, reason, evidence, and authority context.",
"exception": "A subject-and-entry decision bounded by explicit validity and expiry.",
"assurance_revision": "An effective-dated immutable revision of an obligation, risk, control, evidence, finding, measure, or review.",
},
"limitations": [
"Fuzzy matching only creates candidates and never confirms a sanctions match or legal prohibition.",
"Risk Compliance does not replace source acquisition, governed domain objects, Policy decisions, Audit evidence, or Records retention.",
],
"privacy_notes": [
"Queue and audit summaries use stable references and the minimum necessary subject data.",
"Governed domain objects are linked through opaque references rather than copied into the assurance graph.",
], ],
"assurance_graph": (
"Every node and edge is effective-dated, revisioned, tenant-scoped, "
"and linked through opaque governed-object references."
),
"help_contexts": [ "help_contexts": [
"risk_compliance.workspace", "risk_compliance.workspace",
"risk_compliance.sanctions.sources", "risk_compliance.sanctions.sources",
@@ -392,6 +411,77 @@ DOCUMENTATION = (
"revise_assurance_object": "append a new effective-dated revision while preserving prior evidence", "revise_assurance_object": "append a new effective-dated revision while preserving prior evidence",
"connect_assurance_objects": "append a governed typed relationship between assurance objects", "connect_assurance_objects": "append a governed typed relationship between assurance objects",
}, },
"verification": [
"Every screening result names the exact list snapshot, subject fingerprint, policy provenance, and run revision.",
"Every candidate remains pending until an authorized reviewer appends a disposition or exception.",
"Every assurance node and edge is tenant-scoped, effective-dated, revisioned, and linked by governed references.",
],
},
translations={
"de": {
"title": "Sanktionsprüfung und Assurance-Nachweise steuern",
"summary": (
"Versionsgebundene Sanktionsprüfungen durchführen und Risiken, Kontrollen, "
"Nachweise, Feststellungen und Korrekturmaßnahmen verknüpfen, ohne rechtliche Schlüsse zu automatisieren."
),
"body": (
"Connectors können Quellnachweise beschaffen; Risk Compliance führt jedoch unveränderliche "
"normalisierte Sanktionslisten, versionsgebundene Prüfungen, Kandidatenbewertungen und rechtliche "
"Dispositionen. Unscharfer Abgleich erzeugt nur Kandidaten und bestätigt niemals einen Treffer. "
"Die weitergehende Modulrichtung verknüpft Verpflichtungen, gesteuerte Objektreferenzen, Risiken, "
"Kontrollen, Nachweise, Feststellungen, Korrekturmaßnahmen und Wirksamkeitsprüfungen, ohne das "
"gesteuerte Fachobjekt zu kopieren oder Policy und Audit zu ersetzen."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"purpose": (
"Reproduzierbare Prüf- und Assurance-Nachweise erzeugen und rechtliche Bewertung ausdrücklich und menschlich verantwortet halten."
),
"prerequisites": [
"Die handelnde Person darf den jeweiligen Assurance- oder Sanktionsbereich lesen; Import, Prüfung, Bewertung und Bearbeitung verwenden eigene Berechtigungen.",
"Vor dem Import einer Sanktionsliste liegt ein von einem Connector bereitgestellter Quellsnapshot vor.",
"Die minimal erforderlichen Betroffenendaten und eine exakte gesteuerte Betroffenenreferenz sind verfügbar.",
],
"steps": [
"Connector-Nachweise in einen unveränderlichen normalisierten Sanktionslistensnapshot importieren.",
"Eine Prüfung mit nur den erforderlichen Betroffenendaten gegen genau einen fixierten Snapshot ausführen.",
"Jeden unscharfen Kandidaten prüfen und eine nachweisgestützte Disposition oder befristete Ausnahme aufzeichnen.",
"Assurance-Objekte für Verpflichtungen, Risiken, Kontrollen, Nachweise, Feststellungen und Korrekturmaßnahmen anlegen oder revidieren.",
"Assurance-Revisionen durch typisierte gesteuerte Beziehungen verbinden und ihre Wirksamkeit im Zeitverlauf prüfen.",
],
"fields": {
"source_snapshot": "Eine unveränderliche normalisierte Listenrevision mit Connector-Provenienz und Inhaltsfingerabdruck.",
"screening_run": "Ein versionsgebundener Vergleich minimaler Betroffenendaten mit genau einem Quellsnapshot.",
"candidate": "Potenzieller Übereinstimmungsnachweis, der menschliche Prüfung erfordert und allein niemals ein bestätigter Treffer ist.",
"disposition": "Ein nur anfügbares rechtliches Prüfungsergebnis mit prüfender Person, Begründung, Nachweis und Zuständigkeitskontext.",
"exception": "Eine Entscheidung für Betroffenen- und Listeneintrag mit ausdrücklicher Gültigkeit und Ablaufzeit.",
"assurance_revision": "Eine zeitlich wirksame unveränderliche Revision von Verpflichtung, Risiko, Kontrolle, Nachweis, Feststellung, Maßnahme oder Prüfung.",
},
"limitations": [
"Unscharfer Abgleich erzeugt nur Kandidaten und bestätigt niemals einen Sanktionstreffer oder ein rechtliches Verbot.",
"Risk Compliance ersetzt weder Quellenbeschaffung noch gesteuerte Fachobjekte, Policy-Entscheidungen, Audit-Nachweise oder Records-Aufbewahrung.",
],
"privacy_notes": [
"Warteschlangen- und Auditübersichten verwenden stabile Referenzen und die minimal erforderlichen Betroffenendaten.",
"Gesteuerte Fachobjekte werden über opake Referenzen verknüpft und nicht in den Assurance-Graphen kopiert.",
],
"consequence_classes": {
"import_snapshot": "Kopiert Connector-Nachweise in einen unveränderlichen normalisierten Sanktionslistensnapshot.",
"run_screening": "Erzeugt unveränderliche versionsgebundene Prüfnachweise aus den minimal erforderlichen Betroffenendaten.",
"record_disposition": "Fügt eine nachweisgestützte rechtliche Disposition an, die nicht an Ort und Stelle bearbeitet wird.",
"record_exception": "Fügt eine befristete Ausnahme für Betroffenen- und Listeneintrag mit ausdrücklichem Ablauf an.",
"revise_assurance_object": "Fügt eine neue zeitlich wirksame Revision an und bewahrt frühere Nachweise.",
"connect_assurance_objects": "Fügt eine gesteuerte typisierte Beziehung zwischen Assurance-Objekten an.",
},
"verification": [
"Jedes Prüfergebnis nennt exakten Listensnapshot, Betroffenenfingerabdruck, Richtlinienherkunft und Ausführungsrevision.",
"Jeder Kandidat bleibt offen, bis eine befugte prüfende Person eine Disposition oder Ausnahme anfügt.",
"Jeder Assurance-Knoten und jede Kante ist mandantenbegrenzt, zeitlich wirksam, revisioniert und durch gesteuerte Referenzen verknüpft.",
],
}
}, },
), ),
) )
@@ -411,6 +501,10 @@ manifest = ModuleManifest(
name="risk_compliance.sanctions_screening", name="risk_compliance.sanctions_screening",
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider(
name=RISK_COMPLIANCE_DSAR_CAPABILITY,
version="0.1.0",
),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -425,6 +519,17 @@ manifest = ModuleManifest(
route_factory=_route_factory, route_factory=_route_factory,
capability_factories={ capability_factories={
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider), CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider),
RISK_COMPLIANCE_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
RISK_COMPLIANCE_DSAR_CAPABILITY: CapabilityDocumentation(
label="Risk Compliance data-subject request provider",
summary=(
"Exports verified screening-subject data and minimized compliance "
"attribution while protecting third-party and legal-review evidence."
),
contract_version="0.1.0",
),
}, },
search_sources=( search_sources=(
SearchSourceProviderRegistration( SearchSourceProviderRegistration(
@@ -454,6 +559,17 @@ manifest = ModuleManifest(
surface_id="risk_compliance.navigation", surface_id="risk_compliance.navigation",
), ),
), ),
product_areas=(
ProductAreaContribution(
id="data-assurance",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.data_assurance",
icon="database-zap",
description="i18n:govoplan-core.product_area.data_assurance_description",
surface_ids=("risk_compliance.navigation", "risk_compliance.workspace"),
order=60,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="risk_compliance.sanctions.sources", id="risk_compliance.sanctions.sources",
@@ -553,7 +669,106 @@ manifest = ModuleManifest(
label="Risk Compliance", label="Risk Compliance",
), ),
), ),
documentation=DOCUMENTATION, documentation=(
DocumentationTopic(
id="risk_compliance.workspace-layout",
title="Risk Compliance workspace layout",
summary="Find workspace actions and read consistently arranged content.",
body="Sources, screening, review, and assurance use the same workspace action bar, cards, typography, metrics, and responsive columns as the rest of GovOPlaN. Reload is at the upper right; source imports, dispositions, and assurance edits remain with their relevant card. Narrow windows stack the cards instead of requiring a separate miniature interface. Reloading does not import a source, execute a screening, or record a disposition. Administrators continue to grant the separate read, screening, review, and administration permissions; the shared presentation does not change legal assurance or review policy.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Risiko und Compliance: Aufbau des Arbeitsbereichs",
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
"body": "Quellen, Screening, Prüfung und Assurance verwenden dieselbe Arbeitsbereichsleiste, Karten, Typografie, Kennzahlen und responsiven Spalten wie das übrige GovOPlaN. Neu laden steht oben rechts; Quellenimporte, Prüfentscheidungen und Assurance-Bearbeitung bleiben bei ihrer jeweiligen Karte. Schmale Fenster ordnen die Karten untereinander an, statt eine verkleinerte Sonderoberfläche zu verwenden. Neu laden importiert keine Quelle, startet kein Screening und erfasst keine Entscheidung. Administratoren vergeben weiterhin getrennte Lese-, Screening-, Prüf- und Administrationsrechte; die gemeinsame Darstellung verändert weder rechtliche Zusicherungen noch Prüfrichtlinien.",
}},
),
DocumentationTopic(
id="risk_compliance.data-subject-requests",
title="Risk and compliance data-subject requests",
summary=(
"Export verified screening-subject data and accountable activity "
"without disclosing third-party sanctions or review evidence."
),
body=(
"Risk Compliance correlates screening subject data only through an "
"exact, separately verified subject reference. An account or membership "
"identifier independently locates the subject's own operator, reviewer, "
"import, exception, and assurance-graph attribution. Searches can narrow "
"to a screening or assurance revision, but an object identifier alone "
"never establishes identity. Subject exports include bounded submitted "
"names, aliases, identifiers, dates, addresses, and the screening "
"lifecycle outcome. They exclude sanctions-entry data about third "
"parties, candidate matching evidence, fingerprints, hashes, policy "
"snapshots, reviewer reasons, authority context, provenance, and evidence "
"references. Version-pinned screenings, dispositions, exceptions, and "
"assurance revisions remain retained legal and accountability evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "access", "audit", "records", "policy"),
order=90,
metadata={
"kind": "reference",
"help_contexts": [
"risk_compliance.sanctions.screening",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_screening_subject": (
"Returns bounded subject input and lifecycle data for an exact reference."
),
"exclude_third_party_evidence": (
"Never returns sanctions entries, match evidence, or protected review payloads."
),
"retain_compliance_evidence": (
"Preserves version-pinned legal, audit, and accountability history."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Risiko und Compliance",
"summary": (
"Verifizierte Daten geprüfter Betroffener und verantwortbare Aktivitäten exportieren, "
"ohne Sanktions- oder Prüfungsnachweise Dritter offenzulegen."
),
"body": (
"Risk Compliance gleicht Daten geprüfter Betroffener nur über eine exakte, getrennt "
"verifizierte Betroffenenreferenz ab. Eine Konto- oder Mitgliedschaftskennung ermittelt "
"unabhängig eigene Zuschreibungen zu Bedienung, Prüfung, Import, Ausnahme und Assurance-Graph. "
"Suchen können auf eine Prüfungs- oder Assurance-Revision eingegrenzt werden; eine "
"Objektkennung allein begründet niemals Identität. Betroffenenexporte enthalten begrenzte "
"übermittelte Namen, Aliase, Kennungen, Daten, Adressen und das Lebenszyklusergebnis der Prüfung. "
"Sanktionsdaten Dritter, Kandidatenabgleichsnachweise, Fingerabdrücke, Prüfsummen, "
"Richtliniensnapshots, Begründungen prüfender Personen, Zuständigkeitskontext, Provenienz und "
"Nachweisreferenzen bleiben ausgeschlossen. Versionsgebundene Prüfungen, Dispositionen, Ausnahmen "
"und Assurance-Revisionen bleiben als rechtliche und verantwortungsbezogene Nachweise erhalten."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_screening_subject": (
"Gibt begrenzte Betroffeneneingaben und Lebenszyklusdaten für eine exakte Referenz zurück."
),
"exclude_third_party_evidence": (
"Gibt niemals Sanktionslisteneinträge, Abgleichsnachweise oder geschützte Prüfungsinhalte zurück."
),
"retain_compliance_evidence": (
"Bewahrt versionsgebundene Rechts-, Audit- und Verantwortungsnachweise auf."
),
}
}
},
),
*DOCUMENTATION,
),
) )
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
user_workflow_scope_condition_issues,
)
from govoplan_risk_compliance.backend.manifest import manifest
class RiskComplianceDocumentationTests(unittest.TestCase):
def test_public_topics_have_complete_german_reference_content(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
self.assertEqual(
len(manifest.documentation), len(topics), "Documentation topic IDs must be unique"
)
self.assertLessEqual(
{
"risk_compliance.module-boundary",
"risk_compliance.data-subject-requests",
"risk_compliance.workspace-layout",
},
set(topics),
)
self.assertLessEqual(
{"user", "admin"},
set(topics["risk_compliance.workspace-layout"].documentation_types),
)
for topic in manifest.documentation:
translation = topic.translations.get("de", {})
self.assertTrue(
all(translation.get(key) for key in ("title", "summary", "body"))
)
self.assertEqual((), documentation_structured_translation_issues(topic))
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
for topic in manifest.documentation:
self.assertEqual((), user_workflow_scope_condition_issues(topic))
if __name__ == "__main__":
unittest.main()
+468
View File
@@ -0,0 +1,468 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskSanctionsEntry,
RiskSanctionsListSnapshot,
RiskScreeningCandidate,
RiskScreeningDisposition,
RiskScreeningException,
RiskScreeningRun,
RiskScreeningSubjectSnapshot,
)
from govoplan_risk_compliance.backend.dsar_provider import (
RISK_COMPLIANCE_DSAR_CAPABILITY,
RiskComplianceDsarProvider,
)
from govoplan_risk_compliance.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: RiskComplianceDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (RISK_COMPLIANCE_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != RISK_COMPLIANCE_DSAR_CAPABILITY:
raise KeyError(name)
return "risk_compliance"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("risk_compliance",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != RISK_COMPLIANCE_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "risk_compliance"})(),)
class RiskComplianceDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = RiskComplianceDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
list_snapshot = RiskSanctionsListSnapshot(
id="list-1",
tenant_id="tenant-1",
visibility="tenant",
connector_snapshot_ref="connector:snapshot:secret-do-not-export",
provider_id="un",
publisher="United Nations",
jurisdiction="global",
list_type="sanctions",
source_id="consolidated",
source_version="2026-08-22",
publication_at=NOW,
effective_at=NOW,
acquired_at=NOW,
sha256="list-sha-do-not-export",
connector_run_id="connector-run-do-not-export",
raw_evidence_ref="evidence-ref-do-not-export",
source_parser_version="parser-v1",
normalization_version="normalizer-v1",
signature_evidence={"secret": "signature-do-not-export"},
provenance={"secret": "provenance-do-not-export"},
entry_count=1,
status="active",
imported_by="account-1",
imported_at=NOW,
created_at=NOW,
updated_at=NOW,
)
subject_snapshot = RiskScreeningSubjectSnapshot(
id="subject-snapshot-1",
tenant_id="tenant-1",
subject_ref="party:person-1",
subject_type="person",
primary_name="Ada Example",
normalized_name="ada example normalized-do-not-export",
aliases=["Ada E."],
identifiers=[
{
"type": "resident-number",
"value": "resident-123",
"unexpected": "identifier-extra-do-not-export",
}
],
dates=["1990-01-01"],
addresses=[
{
"street": "Example Street 1",
"city": "Exampletown",
"country": "DE",
"unexpected": "address-extra-do-not-export",
}
],
fingerprint="subject-fingerprint-do-not-export",
submitted_by="account-1",
created_at=NOW,
updated_at=NOW,
)
run = RiskScreeningRun(
id="screening-1",
tenant_id="tenant-1",
subject_snapshot_id="subject-snapshot-1",
list_snapshot_id="list-1",
idempotency_key="screening-idempotency-do-not-export",
request_hash="screening-request-hash-do-not-export",
matcher_version="matcher-v1",
normalization_version="normalizer-v1",
policy_version="policy-v1",
policy_snapshot={"secret": "policy-snapshot-do-not-export"},
status="complete",
outcome="review",
candidate_count=1,
started_at=NOW,
completed_at=NOW,
created_by="account-1",
created_at=NOW,
updated_at=NOW,
)
sanctions_entry = RiskSanctionsEntry(
id="entry-1",
snapshot_id="list-1",
source_entry_id="third-party-entry-do-not-export",
subject_type="person",
primary_name="Third Party Name Do Not Export",
normalized_name="third party",
original_script_name=None,
reference_number="third-party-reference-do-not-export",
listed_on=None,
programmes=[],
measures=[],
raw_evidence_locator="third-party-evidence-do-not-export",
details={"secret": "third-party-details-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
candidate = RiskScreeningCandidate(
id="candidate-1",
tenant_id="tenant-1",
run_id="screening-1",
entry_id="entry-1",
score=91,
match_kind="fuzzy",
evidence=[{"secret": "candidate-evidence-do-not-export"}],
review_status="confirmed",
current_disposition_id="disposition-1",
created_at=NOW,
updated_at=NOW,
)
disposition = RiskScreeningDisposition(
id="disposition-1",
tenant_id="tenant-1",
candidate_id="candidate-1",
decision="false_positive",
reason="review-reason-do-not-export",
evidence_refs=["review-evidence-do-not-export"],
scope="subject_entry",
expires_at=NOW + timedelta(days=30),
review_at=NOW + timedelta(days=15),
actor_account_id="account-1",
actor_membership_id="membership-1",
actor_authority={"secret": "authority-do-not-export"},
separation_status="independent",
override_reason="override-reason-do-not-export",
created_at=NOW,
)
exception = RiskScreeningException(
id="exception-1",
tenant_id="tenant-1",
subject_fingerprint="exception-fingerprint-do-not-export",
source_entry_ref="exception-source-entry-do-not-export",
scope="subject_entry",
status="active",
reason="exception-reason-do-not-export",
evidence_refs=["exception-evidence-do-not-export"],
starts_at=NOW,
expires_at=NOW + timedelta(days=30),
review_at=NOW + timedelta(days=15),
originating_disposition_id="disposition-1",
created_by="account-1",
created_at=NOW,
updated_at=NOW,
)
node = RiskAssuranceNode(
id="node-1",
tenant_id="tenant-1",
stable_id="control-1",
kind="control",
revision=1,
label="Sensitive control label do not export",
description="Sensitive control description do not export",
state="active",
owner_ref="owner-secret-do-not-export",
scope_ref="scope-secret-do-not-export",
governed_object_ref="object-secret-do-not-export",
valid_from=NOW,
recorded_at=NOW,
provenance={"secret": "node-provenance-do-not-export"},
legal_basis_refs=["legal-secret-do-not-export"],
policy_refs=["policy-secret-do-not-export"],
evidence_refs=["node-evidence-do-not-export"],
classification="restricted",
created_by="account-1",
created_at=NOW,
updated_at=NOW,
)
edge = RiskAssuranceEdge(
id="edge-1",
tenant_id="tenant-1",
stable_id="relation-1",
revision=1,
source_node_ref="control-1",
target_node_ref="risk-1",
relation="mitigates",
state="active",
owner_ref="edge-owner-secret-do-not-export",
scope_ref="edge-scope-secret-do-not-export",
valid_from=NOW,
recorded_at=NOW,
provenance={"secret": "edge-provenance-do-not-export"},
legal_basis_refs=["edge-legal-secret-do-not-export"],
policy_refs=["edge-policy-secret-do-not-export"],
evidence_refs=["edge-evidence-do-not-export"],
created_by="account-1",
created_at=NOW,
updated_at=NOW,
)
other_tenant_node = RiskAssuranceNode(
id="node-other",
tenant_id="tenant-2",
stable_id="other-control",
kind="control",
revision=1,
label="Other tenant",
state="active",
owner_ref="owner",
valid_from=NOW,
recorded_at=NOW,
provenance={},
legal_basis_refs=[],
policy_refs=[],
evidence_refs=[],
classification="internal",
created_by="account-1",
)
self.session.add_all(
(
list_snapshot,
subject_snapshot,
run,
sanctions_entry,
candidate,
disposition,
exception,
node,
edge,
other_tenant_node,
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(
account_id="account-1",
membership_id="membership-1",
external_references={
"risk_compliance.subject": "party:person-1",
},
)
def test_search_exports_subject_data_and_minimized_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"screening_subject_submission",
"screening_actor_attribution",
"snapshot_import_actor_attribution",
"disposition_actor_attribution",
"exception_actor_attribution",
"assurance_node_actor_attribution",
"assurance_edge_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("Ada Example", exported)
self.assertIn("resident-123", exported)
for excluded in (
"normalized-do-not-export",
"identifier-extra-do-not-export",
"address-extra-do-not-export",
"subject-fingerprint-do-not-export",
"screening-idempotency-do-not-export",
"screening-request-hash-do-not-export",
"policy-snapshot-do-not-export",
"Third Party Name Do Not Export",
"third-party-reference-do-not-export",
"candidate-evidence-do-not-export",
"review-reason-do-not-export",
"review-evidence-do-not-export",
"authority-do-not-export",
"override-reason-do-not-export",
"exception-fingerprint-do-not-export",
"exception-source-entry-do-not-export",
"exception-reason-do-not-export",
"Sensitive control label do not export",
"node-provenance-do-not-export",
"edge-owner-secret-do-not-export",
"edge-evidence-do-not-export",
"list-sha-do-not-export",
"signature-do-not-export",
):
self.assertNotIn(excluded, exported)
def test_subject_data_requires_exact_module_reference(self) -> None:
account_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
reference_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"risk_compliance.subject": "party:person-1",
}
),
)
self.assertNotIn(
"screening_subject_submission",
{record.resource_type for record in account_only},
)
self.assertEqual(
{"screening_subject_submission"},
{record.resource_type for record in reference_only},
)
def test_conflicts_narrowing_and_tenant_boundaries(self) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={
"risk_compliance.account": "account-other",
},
),
)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={
"risk_compliance.subject": "party:person-1",
"risk_compliance.screening": "screening-1",
},
),
)
full = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual((), conflict)
self.assertEqual(
{
"screening_subject_submission",
"screening_actor_attribution",
"disposition_actor_attribution",
},
{record.resource_type for record in narrowed},
)
self.assertNotIn("node-other", {record.resource_id for record in full})
def test_erasure_retains_legal_and_accountability_evidence(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
records=records,
)
self.assertTrue(actions)
self.assertTrue(
all(action.kind == "retain" and not action.executable for action in actions)
)
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-risk-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(RISK_COMPLIANCE_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"risk_compliance.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-RISK-1",
request_kind="access",
subject=self._subject(),
purpose="Risk screening access request",
legal_basis=None,
due_at=None,
requested_by_account_id="operator-1",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=row.resource_revision,
)
self.assertEqual("searched", row.status)
self.assertEqual(7, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
+7 -7
View File
@@ -6,6 +6,9 @@ from govoplan_core.core.sanctions import (
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING, CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
SanctionsScreeningProvider, SanctionsScreeningProvider,
) )
from govoplan_risk_compliance.backend.dsar_provider import (
RISK_COMPLIANCE_DSAR_CAPABILITY,
)
from govoplan_risk_compliance.backend.manifest import ( from govoplan_risk_compliance.backend.manifest import (
ADMIN_SCOPE, ADMIN_SCOPE,
READ_SCOPE, READ_SCOPE,
@@ -27,10 +30,7 @@ class ManifestTests(unittest.TestCase):
self.assertEqual(manifest.dependencies, ("access",)) self.assertEqual(manifest.dependencies, ("access",))
self.assertIn("connectors", manifest.optional_dependencies) self.assertIn("connectors", manifest.optional_dependencies)
self.assertEqual( self.assertEqual(
{ {permission.scope for permission in manifest.permissions},
permission.scope
for permission in manifest.permissions
},
{ {
READ_SCOPE, READ_SCOPE,
WRITE_SCOPE, WRITE_SCOPE,
@@ -59,11 +59,11 @@ class ManifestTests(unittest.TestCase):
) )
self.assertTrue(manifest.requires_interfaces[0].optional) self.assertTrue(manifest.requires_interfaces[0].optional)
self.assertEqual( self.assertEqual(
{"risk_compliance.sanctions_screening"},
{ {
item.name "risk_compliance.sanctions_screening",
for item in manifest.provides_interfaces RISK_COMPLIANCE_DSAR_CAPABILITY,
}, },
{item.name for item in manifest.provides_interfaces},
) )
capability = manifest.capability_factories[ capability = manifest.capability_factories[
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/risk-compliance-webui", "name": "@govoplan/risk-compliance-webui",
"version": "0.1.16", "version": "0.1.21",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/risk-compliance.css": "./src/styles/risk-compliance.css" "./styles/risk-compliance.css": "./src/styles/risk-compliance.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.16", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import { import {
CheckCircle2, CheckCircle2,
Database, Database,
@@ -5,7 +6,6 @@ import {
Pencil, Pencil,
Play, Play,
Plus, Plus,
RefreshCw,
Scale, Scale,
Upload Upload
} from "lucide-react"; } from "lucide-react";
@@ -17,22 +17,29 @@ import {
type FormEvent type FormEvent
} from "react"; } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { import { FormGrid,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
Card,
ConfirmDialog, ConfirmDialog,
ContentGrid,
DescriptionList,
DescriptionItem,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink, DocumentationHelpLink,
FormField, FormField,
IconButton, IconButton,
LoadingIndicator,
MetricCard, MetricCard,
PageScrollViewport,
SegmentedControl, SegmentedControl,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
StatePanel,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
WorkspaceActionBar,
WorkspaceFrame,
hasScope, hasScope,
i18nMessage, i18nMessage,
useUnsavedDraftGuard, useUnsavedDraftGuard,
@@ -246,9 +253,14 @@ export default function RiskCompliancePage({
} }
return ( return (
<main className="risk-page"> <WorkspaceFrame as="main" surface="plain" label="Risk Compliance workspace" interfaceId="risk_compliance.workspace" helpContextId="risk_compliance.workspace" helpModuleId="risk_compliance">
<div className="risk-toolbar"> <WorkspaceActionBar
<SegmentedControl scope="workspace"
variant="workspace"
refreshable
reloadAction={{ onReload: () => void refresh(), loading, disabled: busy, disabledReason: busy ? RISK_COMPLIANCE_I18N.busy : undefined }}
helpAction={<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />}
contextActions={<SegmentedControl
value={view} value={view}
onChange={setView} onChange={setView}
ariaLabel="Risk Compliance view" ariaLabel="Risk Compliance view"
@@ -280,7 +292,7 @@ export default function RiskCompliancePage({
<Scale size={15} /> <Scale size={15} />
Review Review
{queue.length > 0 && ( {queue.length > 0 && (
<span className="risk-count">{queue.length}</span> <StatusBadge status="info" label={String(queue.length)} />
)} )}
</> </>
), ),
@@ -299,25 +311,16 @@ export default function RiskCompliancePage({
title: !canReadAssurance ? RISK_COMPLIANCE_I18N.assuranceReadRequired : undefined title: !canReadAssurance ? RISK_COMPLIANCE_I18N.assuranceReadRequired : undefined
} }
]} ]}
/>}
/> />
<span className="risk-toolbar-spacer" /> <PageScrollViewport>
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} /> <ContentGrid columns={1} className="content-pad">
{loading && <LoadingIndicator size="sm" label="Loading" />}
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void refresh()}
disabled={loading || busy}
disabledReason={loading ? RISK_COMPLIANCE_I18N.loading : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
/>
</div>
{(error || notice) && ( {(error || notice) && (
<div className="risk-alerts"> <div className="risk-alerts">
{error && ( {error && (
<DismissibleAlert <DismissibleAlert
tone="danger" tone="danger"
resetKey={error} resetKey={error}
onDismiss={() => setError("")}
> >
{error} {error}
</DismissibleAlert> </DismissibleAlert>
@@ -326,14 +329,12 @@ export default function RiskCompliancePage({
<DismissibleAlert <DismissibleAlert
tone="success" tone="success"
resetKey={notice} resetKey={notice}
onDismiss={() => setNotice("")}
> >
{notice} {notice}
</DismissibleAlert> </DismissibleAlert>
)} )}
</div> </div>
)} )}
<div className="risk-workspace">
{view === "sources" && ( {view === "sources" && (
<SourcesPane <SourcesPane
available={sourcesAvailable} available={sourcesAvailable}
@@ -383,7 +384,8 @@ export default function RiskCompliancePage({
onRefresh={refresh} onRefresh={refresh}
/> />
)} )}
</div> </ContentGrid>
</PageScrollViewport>
<ConfirmDialog <ConfirmDialog
open={Boolean(pendingImport)} open={Boolean(pendingImport)}
title="i18n:govoplan-risk-compliance.import_snapshot_title" title="i18n:govoplan-risk-compliance.import_snapshot_title"
@@ -396,7 +398,7 @@ export default function RiskCompliancePage({
onCancel={() => setPendingImport(null)} onCancel={() => setPendingImport(null)}
onConfirm={() => pendingImport && void importSnapshot(pendingImport)} onConfirm={() => pendingImport && void importSnapshot(pendingImport)}
/> />
</main> </WorkspaceFrame>
); );
} }
@@ -420,15 +422,8 @@ function SourcesPane({
[imported] [imported]
); );
return ( return (
<section className="risk-source-layout"> <ContentGrid columns={2} collapseAt="wide" align="stretch">
<div className="risk-panel"> <Card title="Connector evidence" actions={<DocumentationHelpLink reference={RISK_COMPLIANCE_ADMIN_DOCUMENTATION} />}>
<header>
<div>
<strong>Connector evidence</strong>
<span>Immutable acquired source snapshots</span>
</div>
<DocumentationHelpLink reference={RISK_COMPLIANCE_ADMIN_DOCUMENTATION} />
</header>
{!available && ( {!available && (
<ActionBlockerHint <ActionBlockerHint
tone="info" tone="info"
@@ -470,14 +465,8 @@ function SourcesPane({
); );
})} })}
</div> </div>
</div> </Card>
<div className="risk-panel"> <Card title="Screening catalogues">
<header>
<div>
<strong>Screening catalogues</strong>
<span>Normalized, immutable list versions</span>
</div>
</header>
<div className="risk-list"> <div className="risk-list">
{imported.map((item) => ( {imported.map((item) => (
<div className="risk-list-row" key={item.id}> <div className="risk-list-row" key={item.id}>
@@ -493,13 +482,11 @@ function SourcesPane({
</div> </div>
))} ))}
{!imported.length && ( {!imported.length && (
<div className="risk-empty"> <StatePanel size="compact" description="No source snapshot has been imported." />
No source snapshot has been imported.
</div>
)} )}
</div> </div>
</div> </Card>
</section> </ContentGrid>
); );
} }
@@ -582,16 +569,10 @@ function ScreenPane({
} }
return ( return (
<section className="risk-screen-layout"> <ContentGrid columns={2} collapseAt="wide" align="stretch">
<form className="risk-panel risk-screen-form" onSubmit={submit}> <Card title="New screening" actions={<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />}>
<header> <form onSubmit={submit}>
<div> <FormGrid columns={1}>
<strong>New screening</strong>
<span>Use only the data needed for comparison</span>
</div>
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
</header>
<div className="risk-form-body">
{!snapshots.length && ( {!snapshots.length && (
<ActionBlockerHint <ActionBlockerHint
reason={{ reason={{
@@ -653,37 +634,20 @@ function ScreenPane({
<Play size={16} /> <Play size={16} />
Run screening Run screening
</Button> </Button>
</div> </FormGrid>
</form> </form>
<div className="risk-panel risk-run-result"> </Card>
<header> <Card title="Result" actions={run && <StatusBadge status={run.outcome} />}>
<div>
<strong>Result</strong>
<span>Version-pinned candidate evidence</span>
</div>
{run && <StatusBadge status={run.outcome} />}
</header>
{!run && ( {!run && (
<div className="risk-empty"> <StatePanel size="compact" description="Run a screening to inspect the result." />
Run a screening to inspect the result.
</div>
)} )}
{run && ( {run && (
<div className="risk-result-body"> <div className="risk-result-body">
<div className="risk-metrics"> <MetricGrid columns={3} density="compact" spacing="none" minimum="compact">
<span> <MetricCard density="compact" surface="flat" label="Candidates" value={run.candidate_count} />
<strong>{run.candidate_count}</strong> <MetricCard density="compact" surface="flat" label="Matcher" value={run.matcher_version} />
candidates <MetricCard density="compact" surface="flat" label="List" value={run.list_snapshot.source_version} />
</span> </MetricGrid>
<span>
<strong>{run.matcher_version}</strong>
matcher
</span>
<span>
<strong>{run.list_snapshot.source_version}</strong>
list
</span>
</div>
{run.candidates.map((item) => ( {run.candidates.map((item) => (
<div className="risk-candidate-summary" key={item.id}> <div className="risk-candidate-summary" key={item.id}>
<span className="risk-score">{item.score}</span> <span className="risk-score">{item.score}</span>
@@ -704,8 +668,8 @@ function ScreenPane({
)} )}
</div> </div>
)} )}
</div> </Card>
</section> </ContentGrid>
); );
} }
@@ -796,14 +760,8 @@ function ReviewPane({
} }
return ( return (
<section className="risk-review-layout"> <ContentGrid columns={2} collapseAt="wide" align="stretch">
<aside className="risk-panel risk-review-queue"> <Card title="Review queue" actions={<StatusBadge status="info" label={String(queue.length)} />}>
<header>
<div>
<strong>Review queue</strong>
<span>{queue.length} candidates need review</span>
</div>
</header>
<div className="risk-list"> <div className="risk-list">
{queue.length > 0 && ( {queue.length > 0 && (
<SelectionList label="Review candidates"> <SelectionList label="Review candidates">
@@ -825,38 +783,29 @@ function ReviewPane({
</SelectionList> </SelectionList>
)} )}
{!queue.length && ( {!queue.length && (
<div className="risk-empty"> <StatePanel size="compact" description="No candidates currently need review." />
No candidates currently need review.
</div>
)} )}
</div> </div>
</aside> </Card>
<div className="risk-panel risk-evidence"> <Card title="Candidate evidence" actions={<>
<header>
<div>
<strong>Candidate evidence</strong>
<span>Subject and immutable list entry comparison</span>
</div>
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} /> <DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<Button <Button
variant="primary" variant="primary"
helpContextId="risk_compliance.review.disposition"
helpModuleId="risk_compliance"
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
disabled={!detail || busy} disabled={!detail || busy}
disabledReason={!detail ? RISK_COMPLIANCE_I18N.candidateRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined} disabledReason={!detail ? RISK_COMPLIANCE_I18N.candidateRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
> >
Record disposition Record disposition
</Button> </Button>
</div> </>}>
</header>
{!detail && ( {!detail && (
<div className="risk-empty"> <StatePanel size="compact" description="Select a candidate from the queue." />
Select a candidate from the queue.
</div>
)} )}
{detail && ( {detail && (
<div className="risk-evidence-body"> <div className="risk-evidence-body">
<div className="risk-comparison"> <ContentGrid columns={2}>
<EvidenceColumn <EvidenceColumn
title="Screening subject" title="Screening subject"
name={detail.subject.primary_name || "Identifier-only"} name={detail.subject.primary_name || "Identifier-only"}
@@ -882,7 +831,7 @@ function ReviewPane({
(item) => item.value (item) => item.value
)} )}
/> />
</div> </ContentGrid>
<div className="risk-match-evidence"> <div className="risk-match-evidence">
<strong> <strong>
{detail.candidate.score}% ·{" "} {detail.candidate.score}% ·{" "}
@@ -899,7 +848,7 @@ function ReviewPane({
</div> </div>
</div> </div>
)} )}
</div> </Card>
<Dialog <Dialog
open={dialogOpen} open={dialogOpen}
title="Record screening disposition" title="Record screening disposition"
@@ -970,7 +919,7 @@ function ReviewPane({
<> <>
<ToggleSwitch <ToggleSwitch
checked={reusable} checked={reusable}
onChange={(event) => setReusable(event.target.checked)} onChange={setReusable}
label="Apply as a time-bounded exception to this subject and list entry" label="Apply as a time-bounded exception to this subject and list entry"
/> />
{reusable && ( {reusable && (
@@ -987,7 +936,7 @@ function ReviewPane({
)} )}
</form> </form>
</Dialog> </Dialog>
</section> </ContentGrid>
); );
} }
@@ -1233,8 +1182,8 @@ function AssurancePane({
} }
return ( return (
<section className="risk-assurance-layout"> <ContentGrid columns={1}>
<div className="risk-assurance-summary"> <ContentGrid columns={1}>
{!canWrite && ( {!canWrite && (
<ActionBlockerHint <ActionBlockerHint
tone="info" tone="info"
@@ -1249,7 +1198,7 @@ function AssurancePane({
documentation={RISK_COMPLIANCE_DOCUMENTATION} documentation={RISK_COMPLIANCE_DOCUMENTATION}
/> />
)} )}
<div className="metric-grid risk-assurance-metrics"> <MetricGrid minimum="fluid" className="risk-assurance-metrics">
<MetricCard label="Objects" value={summary?.node_count ?? 0} tone="neutral" /> <MetricCard label="Objects" value={summary?.node_count ?? 0} tone="neutral" />
<MetricCard label="Relationships" value={summary?.edge_count ?? 0} tone="neutral" /> <MetricCard label="Relationships" value={summary?.edge_count ?? 0} tone="neutral" />
<MetricCard label="Risks" value={summary?.by_kind.risk ?? 0} tone="warning" /> <MetricCard label="Risks" value={summary?.by_kind.risk ?? 0} tone="warning" />
@@ -1258,16 +1207,10 @@ function AssurancePane({
value={summary?.by_state.open ?? 0} value={summary?.by_state.open ?? 0}
tone={(summary?.by_state.open ?? 0) > 0 ? "danger" : "good"} tone={(summary?.by_state.open ?? 0) > 0 ? "danger" : "good"}
/> />
</div> </MetricGrid>
</div> </ContentGrid>
<div className="risk-assurance-columns"> <ContentGrid columns={2} collapseAt="wide" align="stretch">
<aside className="risk-panel"> <Card title="Assurance objects" actions={<>
<header>
<div>
<strong>Assurance objects</strong>
<span>Current effective revisions</span>
</div>
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} /> <DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<IconButton <IconButton
label="Add assurance object" label="Add assurance object"
@@ -1276,9 +1219,8 @@ function AssurancePane({
disabled={!canWrite || busy} disabled={!canWrite || busy}
disabledReason={!canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined} disabledReason={!canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
/> />
</div> </>}>
</header> <FormGrid columns={2} gap="small" spacing="block">
<div className="risk-assurance-filter">
<input <input
type="search" type="search"
value={query} value={query}
@@ -1296,7 +1238,7 @@ function AssurancePane({
<option value={item} key={item}>{formatToken(item)}</option> <option value={item} key={item}>{formatToken(item)}</option>
))} ))}
</select> </select>
</div> </FormGrid>
<div className="risk-list"> <div className="risk-list">
{visibleNodes.length > 0 && ( {visibleNodes.length > 0 && (
<SelectionList label="Assurance objects"> <SelectionList label="Assurance objects">
@@ -1317,17 +1259,11 @@ function AssurancePane({
</SelectionList> </SelectionList>
)} )}
{!visibleNodes.length && ( {!visibleNodes.length && (
<div className="risk-empty">No assurance objects match.</div> <StatePanel size="compact" description="No assurance objects match." />
)} )}
</div> </div>
</aside> </Card>
<div className="risk-panel risk-assurance-detail"> <Card title={selected?.label || "Assurance object"} actions={<>
<header>
<div>
<strong>{selected?.label || "Assurance object"}</strong>
<span>{selected ? formatToken(selected.kind) : "Select an object"}</span>
</div>
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} /> <DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<IconButton <IconButton
label="Edit assurance object" label="Edit assurance object"
@@ -1336,23 +1272,23 @@ function AssurancePane({
disabled={!selected || !canWrite || selected.stable_id.startsWith("sanctions-") || busy} disabled={!selected || !canWrite || selected.stable_id.startsWith("sanctions-") || busy}
disabledReason={!selected ? RISK_COMPLIANCE_I18N.assuranceObjectRequired : !canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : selected.stable_id.startsWith("sanctions-") ? RISK_COMPLIANCE_I18N.systemManagedObject : busy ? RISK_COMPLIANCE_I18N.busy : undefined} disabledReason={!selected ? RISK_COMPLIANCE_I18N.assuranceObjectRequired : !canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : selected.stable_id.startsWith("sanctions-") ? RISK_COMPLIANCE_I18N.systemManagedObject : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
/> />
</div> </>}>
</header>
{!selected && ( {!selected && (
<div className="risk-empty">Select an assurance object.</div> <StatePanel size="compact" description="Select an assurance object." />
)} )}
{selected && ( {selected && (
<div className="risk-assurance-detail-body"> <div className="risk-assurance-detail-body">
<dl className="risk-assurance-properties"> <DescriptionList columns={2}>
<div><dt>State</dt><dd><StatusBadge status={selected.state} /></dd></div> <DescriptionItem term="Type">{formatToken(selected.kind)}</DescriptionItem>
<div><dt>Owner</dt><dd>{selected.owner_ref}</dd></div> <DescriptionItem term="State"><StatusBadge status={selected.state} /></DescriptionItem>
<div><dt>Scope</dt><dd>{selected.scope_ref || "Tenant"}</dd></div> <DescriptionItem term="Owner">{selected.owner_ref}</DescriptionItem>
<div><dt>Valid from</dt><dd>{formatDate(selected.valid_from)}</dd></div> <DescriptionItem term="Scope">{selected.scope_ref || "Tenant"}</DescriptionItem>
<DescriptionItem term="Valid from">{formatDate(selected.valid_from)}</DescriptionItem>
{selected.governed_object_ref && ( {selected.governed_object_ref && (
<div><dt>Governed object</dt><dd><code>{selected.governed_object_ref}</code></dd></div> <DescriptionItem term="Governed object"><code>{selected.governed_object_ref}</code></DescriptionItem>
)} )}
<div><dt>Classification</dt><dd>{selected.classification}</dd></div> <DescriptionItem term="Classification">{selected.classification}</DescriptionItem>
</dl> </DescriptionList>
{selected.description && <p>{selected.description}</p>} {selected.description && <p>{selected.description}</p>}
<div className="risk-assurance-links-header"> <div className="risk-assurance-links-header">
<div> <div>
@@ -1379,7 +1315,7 @@ function AssurancePane({
/> />
))} ))}
{!graph?.edges.length && ( {!graph?.edges.length && (
<div className="risk-empty">No relationships are recorded.</div> <StatePanel size="inline" description="No relationships are recorded." />
)} )}
</div> </div>
{graph?.truncated && ( {graph?.truncated && (
@@ -1389,8 +1325,8 @@ function AssurancePane({
)} )}
</div> </div>
)} )}
</div> </Card>
</div> </ContentGrid>
<AssuranceNodeDialog <AssuranceNodeDialog
open={nodeDialogOpen} open={nodeDialogOpen}
busy={busy} busy={busy}
@@ -1451,7 +1387,7 @@ function AssurancePane({
</FormField> </FormField>
</form> </form>
</Dialog> </Dialog>
</section> </ContentGrid>
); );
} }
@@ -1523,7 +1459,7 @@ function AssuranceNodeDialog({
> >
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} /> <DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<form id="risk-assurance-node-form" className="risk-assurance-form" onSubmit={onSubmit}> <form id="risk-assurance-node-form" className="risk-assurance-form" onSubmit={onSubmit}>
<div className="risk-assurance-form-grid"> <FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Stable ID"> <FormField label="Stable ID">
<input <input
value={draft.stableId} value={draft.stableId}
@@ -1582,11 +1518,11 @@ function AssuranceNodeDialog({
<FormField label="Valid to"> <FormField label="Valid to">
<input type="datetime-local" value={draft.validTo} onChange={(event) => update({ validTo: event.target.value })} /> <input type="datetime-local" value={draft.validTo} onChange={(event) => update({ validTo: event.target.value })} />
</FormField> </FormField>
</div> </FormGrid>
<FormField label="Description"> <FormField label="Description">
<textarea value={draft.description} onChange={(event) => update({ description: event.target.value })} rows={4} maxLength={20000} /> <textarea value={draft.description} onChange={(event) => update({ description: event.target.value })} rows={4} maxLength={20000} />
</FormField> </FormField>
<div className="risk-assurance-form-grid"> <FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Legal basis references"> <FormField label="Legal basis references">
<textarea value={draft.legalBasisRefs} onChange={(event) => update({ legalBasisRefs: event.target.value })} rows={3} /> <textarea value={draft.legalBasisRefs} onChange={(event) => update({ legalBasisRefs: event.target.value })} rows={3} />
</FormField> </FormField>
@@ -1596,7 +1532,7 @@ function AssuranceNodeDialog({
<FormField label="Evidence references"> <FormField label="Evidence references">
<textarea value={draft.evidenceRefs} onChange={(event) => update({ evidenceRefs: event.target.value })} rows={3} /> <textarea value={draft.evidenceRefs} onChange={(event) => update({ evidenceRefs: event.target.value })} rows={3} />
</FormField> </FormField>
</div> </FormGrid>
</form> </form>
</Dialog> </Dialog>
); );
@@ -1679,7 +1615,7 @@ function dateTimeLocalValue(value: Date) {
} }
function formatToken(value: string) { function formatToken(value: string) {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); return value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
} }
function EvidenceColumn({ function EvidenceColumn({
+73 -479
View File
@@ -1,198 +1,114 @@
.risk-page { /* Domain-specific evidence arrangements only. Workspace geometry, cards,
display: flex; headings, controls, metrics and responsive columns belong to Core. */
.risk-list-main,
.risk-candidate-summary > div,
.risk-evidence-column,
.risk-match-evidence,
.risk-evidence-values {
display: grid;
min-width: 0; min-width: 0;
min-height: 0; gap: var(--space-1);
height: 100%;
flex-direction: column;
background: var(--panel);
} }
.risk-toolbar { .risk-list-main,
.risk-candidate-summary > div {
flex: 1;
}
.risk-list-row,
.risk-candidate-summary,
.risk-clear,
.risk-assurance-links-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: var(--space-2);
flex: 0 0 auto; padding-block: var(--space-3);
min-height: 50px; }
.risk-list-row,
.risk-candidate-summary,
.risk-assurance-links-header {
border-bottom: var(--border-line); border-bottom: var(--border-line);
background: var(--panel-header);
padding: 8px 14px;
} }
.risk-toolbar .segmented-control-option { .risk-list-main strong,
gap: 6px; .risk-list-main span,
.risk-list-main code {
overflow-wrap: anywhere;
} }
.risk-toolbar-spacer { .risk-list-main span,
flex: 1; .risk-list-main code,
.risk-candidate-summary > div > span,
.risk-evidence-column > span,
.risk-evidence-values span,
.risk-match-evidence span,
.risk-match-evidence code,
.risk-assurance-links-header span {
color: var(--muted);
} }
.risk-count { .risk-evidence-values strong {
min-width: 18px; overflow-wrap: anywhere;
border-radius: 9px; font-weight: 500;
background: var(--accent);
color: #fff;
padding: 1px 5px;
font-size: 10px;
text-align: center;
} }
.risk-alerts { .risk-score {
display: inline-grid;
min-width: 2.5em;
flex: 0 0 auto; flex: 0 0 auto;
padding: 10px 14px 0; place-items: center;
padding: var(--space-1);
border: 1px solid var(--warning-border);
border-radius: var(--radius-sm);
background: var(--warning-soft);
color: var(--text-strong);
font-weight: 700;
} }
.risk-workspace { .risk-clear {
min-width: 0; color: var(--success);
min-height: 0;
flex: 1;
overflow: hidden;
padding: 14px;
} }
.risk-source-layout, .risk-evidence-values,
.risk-screen-layout, .risk-match-evidence {
.risk-review-layout, margin-block-start: var(--space-3);
.risk-assurance-layout { }
.risk-assurance-detail-body,
.risk-assurance-form,
.risk-disposition-form {
display: grid; display: grid;
min-width: 0; min-width: 0;
min-height: 0; gap: var(--space-3);
height: 100%;
gap: 12px;
}
.risk-source-layout,
.risk-screen-layout {
grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.35fr);
}
.risk-review-layout {
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
}
.risk-assurance-layout {
grid-template-rows: auto minmax(0, 1fr);
}
.risk-assurance-summary {
display: grid;
min-width: 0;
gap: 10px;
}
.risk-assurance-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.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 { .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; margin: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: var(--text-strong);
font-size: 12px;
} }
.risk-assurance-links-header { .risk-assurance-links-header {
display: flex; flex-wrap: wrap;
align-items: center;
gap: 10px;
min-height: 52px;
border-bottom: var(--border-line);
padding: 8px 12px;
} }
.risk-assurance-links-header > div { .risk-assurance-links-header > div {
display: grid; display: grid;
gap: 2px;
flex: 1; flex: 1;
} gap: var(--space-1);
.risk-assurance-links-header span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links {
min-height: 0;
overflow: auto;
} }
.risk-assurance-links > button { .risk-assurance-links > button {
display: grid; display: flex;
flex-wrap: wrap;
width: 100%; width: 100%;
grid-template-columns: minmax(110px, 0.45fr) minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: var(--space-2);
border: 0; border: 0;
border-bottom: var(--border-line); border-bottom: var(--border-line);
background: transparent; background: transparent;
color: var(--text); color: var(--text);
padding: 9px 12px; padding: var(--space-3) var(--space-1);
text-align: left; text-align: left;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
@@ -202,337 +118,15 @@
background: var(--sidebar-hover-bg); background: var(--sidebar-hover-bg);
} }
.risk-assurance-links > button span { .risk-assurance-links > button strong {
color: var(--muted); flex: 1;
font-size: 11px; overflow-wrap: anywhere;
} }
.risk-assurance-links > button strong { .risk-assurance-links > button span {
overflow: hidden; color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
} }
.risk-assurance-truncated { .risk-assurance-truncated {
border-top: var(--border-line);
color: var(--warning); color: var(--warning);
padding: 9px 12px;
font-size: 11px;
}
.risk-assurance-form {
display: grid;
gap: 12px;
}
.risk-assurance-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.risk-panel {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
overflow: hidden;
}
.risk-panel > header {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 9px 12px;
}
.risk-panel > header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
flex: 1;
}
.risk-panel > header strong {
color: var(--text-strong);
font-size: 13px;
}
.risk-panel > header span {
color: var(--muted);
font-size: 11px;
}
.risk-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.risk-list,
.risk-result-body,
.risk-evidence-body {
min-height: 0;
flex: 1;
overflow: auto;
}
.risk-list-row,
.risk-candidate-summary {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
padding: 8px 11px;
}
.risk-queue-row {
display: flex;
min-height: 58px;
align-items: center;
gap: 10px;
}
.risk-list > .selection-list {
gap: 2px;
padding: 4px;
}
.risk-list-main {
display: grid;
min-width: 0;
gap: 3px;
flex: 1;
}
.risk-list-main strong,
.risk-list-main span,
.risk-list-main code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-list-main strong {
color: var(--text-strong);
font-size: 13px;
}
.risk-list-main span,
.risk-list-main code {
color: var(--muted);
font-size: 11px;
}
.risk-empty {
color: var(--muted);
padding: 24px;
text-align: center;
}
.risk-form-body {
display: grid;
align-content: start;
gap: 14px;
overflow: auto;
padding: 16px;
}
.risk-form-body input,
.risk-form-body select,
.risk-disposition-form input,
.risk-disposition-form select,
.risk-disposition-form textarea {
width: 100%;
box-sizing: border-box;
}
.risk-form-body .btn {
justify-self: start;
}
.risk-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: var(--border-line);
}
.risk-metrics > span {
display: grid;
min-width: 0;
gap: 4px;
border-right: var(--border-line);
color: var(--muted);
padding: 12px;
font-size: 11px;
}
.risk-metrics > span:last-child {
border-right: 0;
}
.risk-metrics strong {
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.risk-score {
display: inline-grid;
width: 38px;
height: 32px;
flex: 0 0 38px;
place-items: center;
border: 1px solid var(--warning-border);
border-radius: 4px;
background: var(--warning-soft);
color: var(--text-strong);
font-weight: 700;
}
.risk-candidate-summary > div {
display: grid;
min-width: 0;
gap: 3px;
flex: 1;
}
.risk-candidate-summary span {
color: var(--muted);
font-size: 11px;
}
.risk-clear {
display: flex;
align-items: center;
gap: 8px;
color: var(--success);
padding: 18px;
}
.risk-comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: var(--border-line);
}
.risk-evidence-column {
display: grid;
align-content: start;
gap: 5px;
min-width: 0;
border-right: var(--border-line);
padding: 16px;
}
.risk-evidence-column:last-child {
border-right: 0;
}
.risk-evidence-column > strong {
color: var(--text-strong);
font-size: 16px;
}
.risk-evidence-column > span {
color: var(--muted);
font-size: 12px;
}
.risk-eyebrow {
text-transform: uppercase;
font-size: 10px !important;
font-weight: 700;
}
.risk-evidence-values {
display: grid;
gap: 3px;
margin-top: 10px;
}
.risk-evidence-values span {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.risk-evidence-values strong {
overflow-wrap: anywhere;
color: var(--text);
font-size: 12px;
font-weight: 500;
}
.risk-match-evidence {
display: grid;
gap: 6px;
padding: 16px;
}
.risk-match-evidence span,
.risk-match-evidence code {
color: var(--muted);
font-size: 11px;
}
.risk-disposition-form {
display: grid;
gap: 14px;
}
@media (max-width: 920px) {
.risk-workspace {
overflow: auto;
}
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout,
.risk-assurance-layout,
.risk-assurance-columns {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-assurance-form-grid,
.risk-assurance-properties {
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-properties > div {
border-right: 0;
}
.risk-panel {
min-height: 340px;
}
.risk-comparison {
grid-template-columns: minmax(0, 1fr);
}
.risk-evidence-column {
border-right: 0;
border-bottom: var(--border-line);
}
} }