feat: implement sanctions screening vertical
This commit is contained in:
@@ -2,22 +2,53 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_risk_compliance.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
||||
from govoplan_risk_compliance.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
SANCTIONS_ADMIN_SCOPE,
|
||||
SANCTIONS_READ_SCOPE,
|
||||
SANCTIONS_REVIEW_SCOPE,
|
||||
SANCTIONS_SCREEN_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
get_manifest,
|
||||
)
|
||||
|
||||
|
||||
class ManifestSeedTests(unittest.TestCase):
|
||||
def test_manifest_registers_seed_contract(self) -> None:
|
||||
class ManifestTests(unittest.TestCase):
|
||||
def test_manifest_registers_runtime_contract(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual(manifest.id, "risk_compliance")
|
||||
self.assertEqual(manifest.name, "Risk Compliance")
|
||||
self.assertEqual(manifest.dependencies, ("access",))
|
||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"risk_compliance_manager", "risk_compliance_viewer"})
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertIn("connectors", manifest.optional_dependencies)
|
||||
self.assertEqual(
|
||||
{
|
||||
permission.scope
|
||||
for permission in manifest.permissions
|
||||
},
|
||||
{
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
SANCTIONS_READ_SCOPE,
|
||||
SANCTIONS_SCREEN_SCOPE,
|
||||
SANCTIONS_REVIEW_SCOPE,
|
||||
SANCTIONS_ADMIN_SCOPE,
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
"risk_compliance_reviewer",
|
||||
{role.slug for role in manifest.role_templates},
|
||||
)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertEqual(
|
||||
"connectors.sanctions_snapshots",
|
||||
manifest.requires_interfaces[0].name,
|
||||
)
|
||||
self.assertTrue(manifest.requires_interfaces[0].optional)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
57
tests/test_migrations.py
Normal file
57
tests/test_migrations.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_risk_compliance.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class RiskComplianceMigrationTests(unittest.TestCase):
|
||||
def test_baseline_creates_screening_evidence_tables(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-risk-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'risk.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("risk_compliance",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"a8b9c0d1e2f3",
|
||||
set(
|
||||
MigrationContext.configure(
|
||||
connection
|
||||
).get_current_heads()
|
||||
),
|
||||
)
|
||||
tables = set(
|
||||
inspect(connection).get_table_names()
|
||||
)
|
||||
self.assertIn(
|
||||
"risk_sanctions_list_snapshots",
|
||||
tables,
|
||||
)
|
||||
self.assertIn("risk_screening_runs", tables)
|
||||
self.assertIn(
|
||||
"risk_screening_dispositions",
|
||||
tables,
|
||||
)
|
||||
self.assertIn(
|
||||
"risk_screening_exceptions",
|
||||
tables,
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
383
tests/test_sanctions_screening.py
Normal file
383
tests/test_sanctions_screening.py
Normal file
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
import hashlib
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.sanctions import (
|
||||
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS,
|
||||
SanctionsSnapshotPayload,
|
||||
SanctionsSnapshotReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base, utcnow
|
||||
from govoplan_risk_compliance.backend.db.models import (
|
||||
RiskSanctionsAddress,
|
||||
RiskSanctionsAlias,
|
||||
RiskSanctionsDate,
|
||||
RiskSanctionsEntry,
|
||||
RiskSanctionsIdentifier,
|
||||
RiskSanctionsListSnapshot,
|
||||
RiskScreeningCandidate,
|
||||
RiskScreeningDisposition,
|
||||
RiskScreeningException,
|
||||
RiskScreeningRun,
|
||||
RiskScreeningSubjectSnapshot,
|
||||
)
|
||||
from govoplan_risk_compliance.backend.permissions import (
|
||||
SANCTIONS_ADMIN_SCOPE,
|
||||
SANCTIONS_READ_SCOPE,
|
||||
SANCTIONS_REVIEW_SCOPE,
|
||||
SANCTIONS_SCREEN_SCOPE,
|
||||
)
|
||||
from govoplan_risk_compliance.backend.review import (
|
||||
DispositionInput,
|
||||
record_disposition,
|
||||
)
|
||||
from govoplan_risk_compliance.backend.sanctions_catalog import (
|
||||
RiskSanctionsAccessError,
|
||||
RiskSanctionsConflictError,
|
||||
import_connector_snapshot,
|
||||
)
|
||||
from govoplan_risk_compliance.backend.screening import (
|
||||
MATCHER_VERSION,
|
||||
ScreeningPolicy,
|
||||
ScreeningSubject,
|
||||
run_screening,
|
||||
)
|
||||
|
||||
|
||||
UN_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CONSOLIDATED_LIST dateGenerated="2026-07-29T00:00:00Z">
|
||||
<INDIVIDUALS>
|
||||
<INDIVIDUAL>
|
||||
<DATAID>42</DATAID>
|
||||
<VERSIONNUM>1</VERSIONNUM>
|
||||
<FIRST_NAME>Example</FIRST_NAME>
|
||||
<SECOND_NAME>Person</SECOND_NAME>
|
||||
<UN_LIST_TYPE>Example programme</UN_LIST_TYPE>
|
||||
<REFERENCE_NUMBER>QI.42.26</REFERENCE_NUMBER>
|
||||
<LISTED_ON>2026-01-02</LISTED_ON>
|
||||
<INDIVIDUAL_ALIAS>
|
||||
<QUALITY>Good</QUALITY>
|
||||
<ALIAS_NAME>Example Alias</ALIAS_NAME>
|
||||
</INDIVIDUAL_ALIAS>
|
||||
<INDIVIDUAL_DOCUMENT>
|
||||
<TYPE_OF_DOCUMENT>Passport</TYPE_OF_DOCUMENT>
|
||||
<NUMBER>P-123 456</NUMBER>
|
||||
</INDIVIDUAL_DOCUMENT>
|
||||
<INDIVIDUAL_DATE_OF_BIRTH>
|
||||
<DATE>1980-01-02</DATE>
|
||||
</INDIVIDUAL_DATE_OF_BIRTH>
|
||||
<INDIVIDUAL_ADDRESS>
|
||||
<CITY>Example City</CITY>
|
||||
<COUNTRY>Example Country</COUNTRY>
|
||||
</INDIVIDUAL_ADDRESS>
|
||||
</INDIVIDUAL>
|
||||
</INDIVIDUALS>
|
||||
<ENTITIES>
|
||||
<ENTITY>
|
||||
<DATAID>84</DATAID>
|
||||
<VERSIONNUM>1</VERSIONNUM>
|
||||
<FIRST_NAME>Example Trading Company</FIRST_NAME>
|
||||
<REFERENCE_NUMBER>QE.84.26</REFERENCE_NUMBER>
|
||||
<LISTED_ON>2026-01-03</LISTED_ON>
|
||||
</ENTITY>
|
||||
</ENTITIES>
|
||||
</CONSOLIDATED_LIST>
|
||||
"""
|
||||
|
||||
TABLES = (
|
||||
RiskSanctionsListSnapshot.__table__,
|
||||
RiskSanctionsEntry.__table__,
|
||||
RiskSanctionsAlias.__table__,
|
||||
RiskSanctionsIdentifier.__table__,
|
||||
RiskSanctionsDate.__table__,
|
||||
RiskSanctionsAddress.__table__,
|
||||
RiskScreeningSubjectSnapshot.__table__,
|
||||
RiskScreeningRun.__table__,
|
||||
RiskScreeningCandidate.__table__,
|
||||
RiskScreeningDisposition.__table__,
|
||||
RiskScreeningException.__table__,
|
||||
)
|
||||
|
||||
|
||||
def principal(
|
||||
account_id: str = "account-1",
|
||||
*,
|
||||
scopes: tuple[str, ...] = (
|
||||
SANCTIONS_READ_SCOPE,
|
||||
SANCTIONS_SCREEN_SCOPE,
|
||||
SANCTIONS_REVIEW_SCOPE,
|
||||
SANCTIONS_ADMIN_SCOPE,
|
||||
),
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=f"membership-{account_id}",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id=account_id),
|
||||
user=SimpleNamespace(id=account_id),
|
||||
)
|
||||
|
||||
|
||||
class _SnapshotProvider:
|
||||
def __init__(self) -> None:
|
||||
digest = hashlib.sha256(UN_XML).hexdigest()
|
||||
self.snapshot = SanctionsSnapshotReference(
|
||||
ref="sanctions-snapshot:fixture",
|
||||
tenant_id="tenant-1",
|
||||
provider_id="synthetic.un_fixture",
|
||||
publisher="United Nations Security Council fixture",
|
||||
jurisdiction="UN",
|
||||
list_type="consolidated_sanctions",
|
||||
source_id="synthetic-un-v1",
|
||||
source_version="fixture-v1",
|
||||
publication_at=utcnow(),
|
||||
effective_at=utcnow(),
|
||||
acquired_at=utcnow(),
|
||||
content_type="application/xml",
|
||||
byte_count=len(UN_XML),
|
||||
sha256=digest,
|
||||
parser_version="unsc-xml-v1",
|
||||
raw_evidence_ref="connector-evidence:fixture",
|
||||
connector_run_id="run-fixture",
|
||||
)
|
||||
|
||||
def list_snapshots(self, session, principal, *, limit=100):
|
||||
del session, principal, limit
|
||||
return (self.snapshot,)
|
||||
|
||||
def get_snapshot(self, session, principal, *, snapshot_ref):
|
||||
del session, principal
|
||||
return self.snapshot if snapshot_ref == self.snapshot.ref else None
|
||||
|
||||
def read_snapshot(self, session, principal, *, snapshot_ref):
|
||||
del session, principal
|
||||
if snapshot_ref != self.snapshot.ref:
|
||||
raise ValueError("not found")
|
||||
return SanctionsSnapshotPayload(
|
||||
snapshot=self.snapshot,
|
||||
content=UN_XML,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||
|
||||
def capability(self, name: str):
|
||||
return self.provider if self.has_capability(name) else None
|
||||
|
||||
|
||||
class SanctionsScreeningTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=TABLES)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = _SnapshotProvider()
|
||||
self.registry = _Registry(self.provider)
|
||||
self.list_snapshot, _ = import_connector_snapshot(
|
||||
self.session,
|
||||
principal(),
|
||||
registry=self.registry,
|
||||
connector_snapshot_ref=self.provider.snapshot.ref,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_import_is_immutable_idempotent_and_normalized(self) -> None:
|
||||
second, created = import_connector_snapshot(
|
||||
self.session,
|
||||
principal(),
|
||||
registry=self.registry,
|
||||
connector_snapshot_ref=self.provider.snapshot.ref,
|
||||
)
|
||||
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(self.list_snapshot.id, second.id)
|
||||
self.assertEqual(2, second.entry_count)
|
||||
person = self.session.query(RiskSanctionsEntry).filter_by(
|
||||
source_entry_id="QI.42.26"
|
||||
).one()
|
||||
self.assertEqual("example person", person.normalized_name)
|
||||
self.assertEqual("example alias", person.aliases[0].normalized_name)
|
||||
self.assertEqual("p123456", person.identifiers[0].normalized_value)
|
||||
self.assertEqual(
|
||||
"INDIVIDUALS/INDIVIDUAL[1]",
|
||||
person.raw_evidence_locator,
|
||||
)
|
||||
|
||||
def test_screening_is_versioned_explainable_and_idempotent(self) -> None:
|
||||
item, created = run_screening(
|
||||
self.session,
|
||||
principal(),
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="screening-1",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
primary_name="Exampel Person",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(created)
|
||||
self.assertEqual("potential", item.outcome)
|
||||
self.assertEqual(MATCHER_VERSION, item.matcher_version)
|
||||
self.assertEqual(1, item.candidate_count)
|
||||
self.assertEqual("name_fuzzy", item.candidates[0].match_kind)
|
||||
self.assertEqual("pending", item.candidates[0].review_status)
|
||||
self.assertFalse(
|
||||
item.candidates[0].evidence[0]["auto_confirmed"]
|
||||
)
|
||||
|
||||
replay, replay_created = run_screening(
|
||||
self.session,
|
||||
principal(),
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="screening-1",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
primary_name="Exampel Person",
|
||||
),
|
||||
)
|
||||
self.assertFalse(replay_created)
|
||||
self.assertEqual(item.id, replay.id)
|
||||
|
||||
with self.assertRaises(RiskSanctionsConflictError):
|
||||
run_screening(
|
||||
self.session,
|
||||
principal(),
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="screening-1",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
primary_name="Different Person",
|
||||
),
|
||||
)
|
||||
|
||||
def test_identifier_match_is_exact(self) -> None:
|
||||
item, _ = run_screening(
|
||||
self.session,
|
||||
principal(),
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="identifier-1",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
identifiers=(
|
||||
{
|
||||
"type": "passport",
|
||||
"value": "P123-456",
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("identifier_exact", item.candidates[0].match_kind)
|
||||
self.assertEqual(100, item.candidates[0].score)
|
||||
|
||||
def test_stale_list_has_explicit_outcome(self) -> None:
|
||||
self.list_snapshot.acquired_at = utcnow() - timedelta(days=30)
|
||||
item, _ = run_screening(
|
||||
self.session,
|
||||
principal(),
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="stale-1",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
primary_name="Example Person",
|
||||
),
|
||||
policy=ScreeningPolicy(max_snapshot_age_days=7),
|
||||
)
|
||||
|
||||
self.assertEqual("stale", item.outcome)
|
||||
self.assertEqual(0, item.candidate_count)
|
||||
|
||||
def test_review_is_separated_and_exception_requires_review(self) -> None:
|
||||
submitter = principal(
|
||||
scopes=(
|
||||
SANCTIONS_READ_SCOPE,
|
||||
SANCTIONS_SCREEN_SCOPE,
|
||||
)
|
||||
)
|
||||
item, _ = run_screening(
|
||||
self.session,
|
||||
submitter,
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="review-1",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
primary_name="Example Person",
|
||||
),
|
||||
)
|
||||
candidate = item.candidates[0]
|
||||
with self.assertRaises(RiskSanctionsAccessError):
|
||||
record_disposition(
|
||||
self.session,
|
||||
principal(
|
||||
scopes=(SANCTIONS_REVIEW_SCOPE,)
|
||||
),
|
||||
candidate_id=candidate.id,
|
||||
disposition=DispositionInput(
|
||||
decision="false_positive",
|
||||
reason="Independent evidence excludes this subject.",
|
||||
),
|
||||
)
|
||||
|
||||
reviewer = principal(
|
||||
"account-2",
|
||||
scopes=(SANCTIONS_REVIEW_SCOPE,),
|
||||
)
|
||||
reviewed, disposition = record_disposition(
|
||||
self.session,
|
||||
reviewer,
|
||||
candidate_id=candidate.id,
|
||||
disposition=DispositionInput(
|
||||
decision="false_positive",
|
||||
reason="Independent evidence excludes this subject.",
|
||||
exception_scope="subject_entry",
|
||||
expires_at=utcnow() + timedelta(days=30),
|
||||
),
|
||||
)
|
||||
self.assertEqual("false_positive", reviewed.review_status)
|
||||
self.assertEqual("independent", disposition.separation_status)
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.session.query(RiskScreeningException).count(),
|
||||
)
|
||||
|
||||
repeated, _ = run_screening(
|
||||
self.session,
|
||||
submitter,
|
||||
list_snapshot_id=self.list_snapshot.id,
|
||||
idempotency_key="review-2",
|
||||
subject=ScreeningSubject(
|
||||
subject_type="person",
|
||||
primary_name="Example Person",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
"exception_review",
|
||||
repeated.candidates[0].review_status,
|
||||
)
|
||||
self.assertEqual(
|
||||
"prior_exception",
|
||||
repeated.candidates[0].evidence[-1]["kind"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user