diff --git a/src/govoplan_core/core/sanctions.py b/src/govoplan_core/core/sanctions.py new file mode 100644 index 0000000..e02a6e9 --- /dev/null +++ b/src/govoplan_core/core/sanctions.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol, runtime_checkable + + +SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1" +CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = ( + "connectors.sanctionsSnapshotProvider" +) + + +@dataclass(frozen=True, slots=True) +class SanctionsSnapshotReference: + ref: str + tenant_id: str + provider_id: str + publisher: str + jurisdiction: str + list_type: str + source_id: str + source_version: str + publication_at: datetime | None + effective_at: datetime | None + acquired_at: datetime + content_type: str + byte_count: int + sha256: str + parser_version: str + raw_evidence_ref: str + connector_run_id: str + signature_evidence: Mapping[str, object] = field( + default_factory=dict + ) + licence_notes: str | None = None + trust_notes: str | None = None + transport_evidence: Mapping[str, object] = field( + default_factory=dict + ) + contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION + + def __post_init__(self) -> None: + if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION: + raise ValueError( + "Unsupported sanctions snapshot contract version." + ) + required = ( + self.ref, + self.tenant_id, + self.provider_id, + self.publisher, + self.jurisdiction, + self.list_type, + self.source_id, + self.source_version, + self.content_type, + self.sha256, + self.parser_version, + self.raw_evidence_ref, + self.connector_run_id, + ) + if not all(value.strip() for value in required): + raise ValueError( + "Sanctions snapshot references require complete provenance." + ) + if self.byte_count < 1: + raise ValueError( + "Sanctions snapshot evidence must not be empty." + ) + if ( + len(self.sha256) != 64 + or any(character not in "0123456789abcdef" for character in self.sha256) + ): + raise ValueError( + "Sanctions snapshot SHA-256 evidence is invalid." + ) + + +@dataclass(frozen=True, slots=True) +class SanctionsSnapshotPayload: + snapshot: SanctionsSnapshotReference + content: bytes + + def __post_init__(self) -> None: + if len(self.content) != self.snapshot.byte_count: + raise ValueError( + "Sanctions snapshot content length does not match metadata." + ) + + +@runtime_checkable +class SanctionsSnapshotProvider(Protocol): + def list_snapshots( + self, + session: object, + principal: object, + *, + limit: int = 100, + ) -> Sequence[SanctionsSnapshotReference]: + ... + + def get_snapshot( + self, + session: object, + principal: object, + *, + snapshot_ref: str, + ) -> SanctionsSnapshotReference | None: + ... + + def read_snapshot( + self, + session: object, + principal: object, + *, + snapshot_ref: str, + ) -> SanctionsSnapshotPayload: + ... + + +def sanctions_snapshot_provider( + registry: object | None, +) -> SanctionsSnapshotProvider | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability( + CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS + ) + ): + return None + provider = registry.capability( + CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS + ) + return ( + provider + if isinstance(provider, SanctionsSnapshotProvider) + else None + ) + + +__all__ = [ + "CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS", + "SANCTIONS_SNAPSHOT_CONTRACT_VERSION", + "SanctionsSnapshotPayload", + "SanctionsSnapshotProvider", + "SanctionsSnapshotReference", + "sanctions_snapshot_provider", +] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 99fed2f..9c13237 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -49,8 +49,8 @@ class Settings(BaseSettings): default=( "tenancy,organizations,identity,idm,access,admin,dashboard,policy," "audit,campaigns,files,mail,calendar,poll,scheduling,connectors," - "datasources,dataflow,workflow,views,search,postbox,notifications," - "docs,ops" + "datasources,dataflow,workflow,views,search,risk_compliance," + "postbox,notifications,docs,ops" ), alias="ENABLED_MODULES", ) diff --git a/tests/test_sanctions_contract.py b/tests/test_sanctions_contract.py new file mode 100644 index 0000000..83b029b --- /dev/null +++ b/tests/test_sanctions_contract.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import unittest + +from govoplan_core.core.sanctions import ( + CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS, + SanctionsSnapshotPayload, + SanctionsSnapshotProvider, + SanctionsSnapshotReference, + sanctions_snapshot_provider, +) + + +class _Provider: + def list_snapshots(self, session, principal, *, limit=100): + del session, principal, limit + return () + + def get_snapshot(self, session, principal, *, snapshot_ref): + del session, principal, snapshot_ref + return None + + def read_snapshot(self, session, principal, *, snapshot_ref): + del session, principal, snapshot_ref + raise LookupError + + +class _Registry: + def __init__(self, provider): + self.provider = provider + + def has_capability(self, name): + return name == CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS + + def capability(self, name): + return self.provider if self.has_capability(name) else None + + +class SanctionsContractTests(unittest.TestCase): + def test_snapshot_evidence_is_versioned_and_bounded(self) -> None: + content = b"" + snapshot = SanctionsSnapshotReference( + ref="sanctions-snapshot:snapshot-1", + tenant_id="tenant-1", + provider_id="un.security_council", + publisher="United Nations Security Council", + jurisdiction="UN", + list_type="consolidated", + source_id="unsc-consolidated", + source_version="2026-07-22", + publication_at=None, + effective_at=None, + acquired_at=datetime.now(timezone.utc), + content_type="application/xml", + byte_count=len(content), + sha256=hashlib.sha256(content).hexdigest(), + parser_version="unsc-xml-v1", + raw_evidence_ref="connector-evidence:snapshot-1", + connector_run_id="run-1", + ) + + payload = SanctionsSnapshotPayload( + snapshot=snapshot, + content=content, + ) + provider = _Provider() + + self.assertEqual(content, payload.content) + self.assertIsInstance(provider, SanctionsSnapshotProvider) + self.assertIs( + provider, + sanctions_snapshot_provider(_Registry(provider)), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index b0f5437..993dd17 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -26,6 +26,7 @@ "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", + "@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", @@ -378,6 +379,22 @@ } } }, + "../../govoplan-risk-compliance/webui": { + "name": "@govoplan/risk-compliance-webui", + "version": "0.1.8", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": ">=7.18.2 <8" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-scheduling/webui": { "name": "@govoplan/scheduling-webui", "version": "0.1.11", @@ -1243,6 +1260,10 @@ "resolved": "../../govoplan-postbox/webui", "link": true }, + "node_modules/@govoplan/risk-compliance-webui": { + "resolved": "../../govoplan-risk-compliance/webui", + "link": true + }, "node_modules/@govoplan/scheduling-webui": { "resolved": "../../govoplan-scheduling/webui", "link": true diff --git a/webui/package.json b/webui/package.json index 41c0494..31b41a3 100644 --- a/webui/package.json +++ b/webui/package.json @@ -57,6 +57,7 @@ "@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", + "@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index d3986a2..5e2fa7c 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -1,4 +1,4 @@ -import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; +import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; import installedWebModules from "virtual:govoplan-installed-modules"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types"; import { @@ -68,6 +68,7 @@ const iconByName: Record = { operator: RadioTower, "radio-tower": RadioTower, reports: ClipboardPenLine, + "shield-check": ShieldCheck, templates: LayoutTemplate, users: Users, waypoints: Waypoints, diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 42a7021..8b93d65 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -29,6 +29,7 @@ const defaultWebModulePackages = [ "@govoplan/ops-webui", "@govoplan/policy-webui", "@govoplan/postbox-webui", + "@govoplan/risk-compliance-webui", "@govoplan/scheduling-webui", "@govoplan/views-webui", "@govoplan/workflow-webui" @@ -129,6 +130,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))