feat: implement sanctions screening vertical
This commit is contained in:
@@ -4,11 +4,13 @@
|
|||||||
**Repository type:** module (domain).
|
**Repository type:** module (domain).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-risk-compliance` is the GovOPlaN platform module seed for risk and compliance workflows for data protection incidents, DPIAs, compliance controls, audit measures, risk registers, and internal-control evidence.
|
`govoplan-risk-compliance` owns risk and compliance workflows for data protection incidents, DPIAs, compliance controls, audit measures, risk registers, internal-control evidence, and legal screening decisions.
|
||||||
|
|
||||||
Its runtime module ID is `risk_compliance`; the repository and Python distribution retain the hyphenated `govoplan-risk-compliance` name.
|
Its runtime module ID is `risk_compliance`; the repository and Python distribution retain the hyphenated `govoplan-risk-compliance` name.
|
||||||
|
|
||||||
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
|
The first complete vertical is sanctions screening. Connectors acquires immutable source evidence; Risk Compliance imports and normalizes exact list versions, runs deterministic version-pinned screening, and presents potential matches to an independent reviewer. Fuzzy matching only creates review candidates and never confirms a legal match.
|
||||||
|
|
||||||
|
The module includes database migrations, tenant-isolated APIs, an operational WebUI, append-only dispositions, time-bounded false-positive exceptions, and audit events. Queue and audit summaries deliberately retain only the minimum subject data needed for the workflow.
|
||||||
|
|
||||||
## Initial Ownership
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ This repository is initialized as a discoverable module seed. It exposes a modul
|
|||||||
- data protection incident records
|
- data protection incident records
|
||||||
- audit measures
|
- audit measures
|
||||||
- internal-control evidence
|
- internal-control evidence
|
||||||
|
- immutable sanctions list catalogues
|
||||||
|
- sanctions screening and reviewer dispositions
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ Expected optional integrations:
|
|||||||
- files
|
- files
|
||||||
- tasks
|
- tasks
|
||||||
- notifications
|
- notifications
|
||||||
|
- connectors
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
|
|||||||
31
package.json
31
package.json
@@ -1,8 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/risk-compliance",
|
"name": "@govoplan/risk-compliance-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Risk Compliance platform module seed.",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/risk-compliance.css": "./webui/src/styles/risk-compliance.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ requires-python = ">=3.12"
|
|||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"defusedxml>=0.7.1",
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.8",
|
||||||
"govoplan-access>=0.1.8",
|
"govoplan-access>=0.1.8",
|
||||||
]
|
]
|
||||||
|
|||||||
2
src/govoplan_risk_compliance/backend/db/__init__.py
Normal file
2
src/govoplan_risk_compliance/backend/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Risk Compliance persistence models."""
|
||||||
|
|
||||||
857
src/govoplan_risk_compliance/backend/db/models.py
Normal file
857
src/govoplan_risk_compliance/backend/db/models.py
Normal file
@@ -0,0 +1,857 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsListSnapshot(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_sanctions_list_snapshots"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"connector_snapshot_ref",
|
||||||
|
name="uq_risk_sanctions_connector_snapshot",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_risk_sanctions_snapshot_current",
|
||||||
|
"tenant_id",
|
||||||
|
"provider_id",
|
||||||
|
"status",
|
||||||
|
"acquired_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
visibility: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default="tenant",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
connector_snapshot_ref: Mapped[str] = mapped_column(
|
||||||
|
String(300),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
provider_id: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
publisher: Mapped[str] = mapped_column(
|
||||||
|
String(300),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
jurisdiction: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
list_type: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_id: Mapped[str] = mapped_column(
|
||||||
|
String(200),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_version: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
publication_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
effective_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
acquired_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sha256: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
connector_run_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
raw_evidence_ref: Mapped[str] = mapped_column(
|
||||||
|
String(300),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
source_parser_version: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
normalization_version: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
signature_evidence: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
entry_count: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="active",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
imported_by: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
imported_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
entries: Mapped[list["RiskSanctionsEntry"]] = relationship(
|
||||||
|
back_populates="snapshot",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsEntry(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_sanctions_entries"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"snapshot_id",
|
||||||
|
"source_entry_id",
|
||||||
|
name="uq_risk_sanctions_entry_source",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_risk_sanctions_entry_name",
|
||||||
|
"snapshot_id",
|
||||||
|
"normalized_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
snapshot_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_list_snapshots.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_entry_id: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
subject_type: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
primary_name: Mapped[str] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
normalized_name: Mapped[str] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
original_script_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
reference_number: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
listed_on: Mapped[date | None] = mapped_column(
|
||||||
|
Date,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
programmes: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
measures: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
raw_evidence_locator: Mapped[str] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot: Mapped[RiskSanctionsListSnapshot] = relationship(
|
||||||
|
back_populates="entries"
|
||||||
|
)
|
||||||
|
aliases: Mapped[list["RiskSanctionsAlias"]] = relationship(
|
||||||
|
back_populates="entry",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
identifiers: Mapped[list["RiskSanctionsIdentifier"]] = relationship(
|
||||||
|
back_populates="entry",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
dates: Mapped[list["RiskSanctionsDate"]] = relationship(
|
||||||
|
back_populates="entry",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
addresses: Mapped[list["RiskSanctionsAddress"]] = relationship(
|
||||||
|
back_populates="entry",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsAlias(Base):
|
||||||
|
__tablename__ = "risk_sanctions_aliases"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_risk_sanctions_alias_name",
|
||||||
|
"entry_id",
|
||||||
|
"normalized_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
entry_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_entries.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
normalized_name: Mapped[str] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
quality: Mapped[str | None] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
alias_type: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
default="aka",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry: Mapped[RiskSanctionsEntry] = relationship(
|
||||||
|
back_populates="aliases"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsIdentifier(Base):
|
||||||
|
__tablename__ = "risk_sanctions_identifiers"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_risk_sanctions_identifier_value",
|
||||||
|
"entry_id",
|
||||||
|
"normalized_value",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
entry_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_entries.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
identifier_type: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
value: Mapped[str] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
normalized_value: Mapped[str] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
issuing_country: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
note: Mapped[str | None] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry: Mapped[RiskSanctionsEntry] = relationship(
|
||||||
|
back_populates="identifiers"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsDate(Base):
|
||||||
|
__tablename__ = "risk_sanctions_dates"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
entry_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_entries.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
date_type: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
value: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
precision: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="unknown",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry: Mapped[RiskSanctionsEntry] = relationship(
|
||||||
|
back_populates="dates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsAddress(Base):
|
||||||
|
__tablename__ = "risk_sanctions_addresses"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
entry_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_entries.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
street: Mapped[str | None] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
city: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
region: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
postal_code: Mapped[str | None] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
country: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
note: Mapped[str | None] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry: Mapped[RiskSanctionsEntry] = relationship(
|
||||||
|
back_populates="addresses"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskScreeningSubjectSnapshot(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_screening_subject_snapshots"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
subject_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
subject_type: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
primary_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
normalized_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(1000),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
aliases: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
identifiers: Mapped[list[dict[str, str]]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
dates: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
addresses: Mapped[list[dict[str, str]]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
fingerprint: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
submitted_by: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskScreeningRun(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_screening_runs"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_risk_screening_run_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_risk_screening_run_status",
|
||||||
|
"tenant_id",
|
||||||
|
"outcome",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
subject_snapshot_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_screening_subject_snapshots.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
list_snapshot_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_list_snapshots.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
request_hash: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
matcher_version: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
normalization_version: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
policy_version: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
outcome: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
candidate_count: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
subject_snapshot: Mapped[RiskScreeningSubjectSnapshot] = relationship()
|
||||||
|
list_snapshot: Mapped[RiskSanctionsListSnapshot] = relationship()
|
||||||
|
candidates: Mapped[list["RiskScreeningCandidate"]] = relationship(
|
||||||
|
back_populates="run",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskScreeningCandidate(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_screening_candidates"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"run_id",
|
||||||
|
"entry_id",
|
||||||
|
name="uq_risk_screening_candidate_entry",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_risk_screening_candidate_queue",
|
||||||
|
"tenant_id",
|
||||||
|
"review_status",
|
||||||
|
"score",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
run_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_screening_runs.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
entry_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_sanctions_entries.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
score: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
match_kind: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
evidence: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
review_status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="pending",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
current_disposition_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
run: Mapped[RiskScreeningRun] = relationship(
|
||||||
|
back_populates="candidates"
|
||||||
|
)
|
||||||
|
entry: Mapped[RiskSanctionsEntry] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class RiskScreeningDisposition(Base):
|
||||||
|
__tablename__ = "risk_screening_dispositions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
candidate_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_screening_candidates.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
decision: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
reason: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
evidence_refs: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
scope: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="candidate",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
review_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
actor_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
actor_membership_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
actor_authority: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
separation_status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
override_reason: Mapped[str | None] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
supersedes_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_screening_dispositions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskScreeningException(Base, TimestampMixin):
|
||||||
|
__tablename__ = "risk_screening_exceptions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_risk_screening_exception_active",
|
||||||
|
"tenant_id",
|
||||||
|
"subject_fingerprint",
|
||||||
|
"source_entry_ref",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
subject_fingerprint: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_entry_ref: Mapped[str] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
scope: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="active",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
reason: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
evidence_refs: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
starts_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
review_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
originating_disposition_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"risk_screening_dispositions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RiskSanctionsAddress",
|
||||||
|
"RiskSanctionsAlias",
|
||||||
|
"RiskSanctionsDate",
|
||||||
|
"RiskSanctionsEntry",
|
||||||
|
"RiskSanctionsIdentifier",
|
||||||
|
"RiskSanctionsListSnapshot",
|
||||||
|
"RiskScreeningCandidate",
|
||||||
|
"RiskScreeningDisposition",
|
||||||
|
"RiskScreeningException",
|
||||||
|
"RiskScreeningRun",
|
||||||
|
"RiskScreeningSubjectSnapshot",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
@@ -1,14 +1,56 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
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 (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "risk_compliance"
|
MODULE_ID = "risk_compliance"
|
||||||
MODULE_NAME = "Risk Compliance"
|
MODULE_NAME = "Risk Compliance"
|
||||||
MODULE_VERSION = "0.1.8"
|
MODULE_VERSION = "0.1.8"
|
||||||
READ_SCOPE = "risk_compliance:workspace:read"
|
|
||||||
WRITE_SCOPE = "risk_compliance:workspace:write"
|
|
||||||
ADMIN_SCOPE = "risk_compliance:workspace:admin"
|
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"audit",
|
"audit",
|
||||||
"policy",
|
"policy",
|
||||||
@@ -17,10 +59,28 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"files",
|
"files",
|
||||||
"tasks",
|
"tasks",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"connectors",
|
||||||
|
)
|
||||||
|
_PERSISTENT_MODELS = (
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningDisposition,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningRun,
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
RiskSanctionsAddress,
|
||||||
|
RiskSanctionsDate,
|
||||||
|
RiskSanctionsIdentifier,
|
||||||
|
RiskSanctionsAlias,
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(
|
||||||
|
scope: str,
|
||||||
|
label: str,
|
||||||
|
description: str,
|
||||||
|
) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
scope=scope,
|
scope=scope,
|
||||||
@@ -35,52 +95,159 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View risk compliance workspace", "Read risk compliance records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage risk compliance workspace", "Create and update risk compliance records and workflow state."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer risk compliance workspace", "Configure risk compliance policies, templates, and tenant-level administration."),
|
"View risk compliance workspace",
|
||||||
|
"Read risk compliance records, configuration, and workflow context.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Manage risk compliance workspace",
|
||||||
|
"Create and update risk compliance records and workflow state.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer risk compliance workspace",
|
||||||
|
"Configure risk compliance policies, templates, and tenant administration.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
"View sanctions screening evidence",
|
||||||
|
"Read list snapshots, screening runs, and candidate evidence.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
"Run sanctions screening",
|
||||||
|
"Submit subjects for deterministic screening against an immutable list.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
"Review sanctions candidates",
|
||||||
|
"Record evidence-backed candidate dispositions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
"Administer sanctions screening",
|
||||||
|
"Import source snapshots, configure policy, and authorize review overrides.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="risk_compliance_manager",
|
slug="risk_compliance_manager",
|
||||||
name="Risk Compliance manager",
|
name="Risk Compliance manager",
|
||||||
description="Manage risk compliance records and workflow state.",
|
description=(
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
"Manage compliance workflows and administer sanctions screening."
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="risk_compliance_reviewer",
|
||||||
|
name="Risk Compliance reviewer",
|
||||||
|
description=(
|
||||||
|
"Run screenings and independently review potential matches."
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
READ_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="risk_compliance_viewer",
|
slug="risk_compliance_viewer",
|
||||||
name="Risk Compliance viewer",
|
name="Risk Compliance viewer",
|
||||||
description="Read risk compliance records and workflow context.",
|
description="Read risk compliance records and screening evidence.",
|
||||||
permissions=(READ_SCOPE,),
|
permissions=(READ_SCOPE, SANCTIONS_READ_SCOPE),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_factory(_context):
|
||||||
|
from govoplan_risk_compliance.backend.router import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"risk_sanctions_list_snapshots": (
|
||||||
|
session.query(RiskSanctionsListSnapshot)
|
||||||
|
.filter(
|
||||||
|
RiskSanctionsListSnapshot.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"risk_screening_runs": (
|
||||||
|
session.query(RiskScreeningRun)
|
||||||
|
.filter(RiskScreeningRun.tenant_id == tenant_id)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"risk_pending_screening_candidates": (
|
||||||
|
session.query(RiskScreeningCandidate)
|
||||||
|
.filter(
|
||||||
|
RiskScreeningCandidate.tenant_id == tenant_id,
|
||||||
|
RiskScreeningCandidate.review_status.in_(
|
||||||
|
("pending", "exception_review")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
DOCUMENTATION = (
|
DOCUMENTATION = (
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
id=f"{MODULE_ID}.module-boundary",
|
||||||
title=f"{MODULE_NAME} module boundary",
|
title=f"{MODULE_NAME} module boundary",
|
||||||
summary="Risk and compliance workflows for data protection incidents, DPIAs, compliance controls, audit measures, risk registers, and internal-control evidence.",
|
summary=(
|
||||||
|
"Risk and compliance workflows own legal evaluation, immutable "
|
||||||
|
"screening evidence, review, and dispositions."
|
||||||
|
),
|
||||||
body=(
|
body=(
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
"Connectors may acquire source evidence, but Risk Compliance "
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
"owns immutable normalized sanctions lists, version-pinned "
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
"screening, candidate review, and legal dispositions. Fuzzy "
|
||||||
|
"matching only creates candidates and never confirms a match."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=(
|
||||||
|
"operator",
|
||||||
|
"module_admin",
|
||||||
|
"compliance_reviewer",
|
||||||
|
),
|
||||||
order=100,
|
order=100,
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Repository domain boundary",
|
label="Repository domain boundary",
|
||||||
href="govoplan-risk-compliance/docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",
|
href=(
|
||||||
|
"govoplan-risk-compliance/"
|
||||||
|
"docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md"
|
||||||
|
),
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"domain_objects": [
|
||||||
"domain_objects": ['risk registers', 'compliance controls', 'DPIA records', 'data protection incident records', 'audit measures', 'internal-control evidence'],
|
"sanctions list snapshots",
|
||||||
"first_slice": "Define risk register, control, evidence, DPIA, incident, measure, and review-cycle concepts.",
|
"screening runs",
|
||||||
|
"candidate evidence",
|
||||||
|
"review dispositions",
|
||||||
|
"time-bounded exceptions",
|
||||||
|
],
|
||||||
|
"privacy": (
|
||||||
|
"Queue and audit summaries contain stable references and "
|
||||||
|
"minimal subject data."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -91,12 +258,111 @@ manifest = ModuleManifest(
|
|||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="connectors.sanctions_snapshots",
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
route_factory=_route_factory,
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/risk-compliance-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/risk-compliance",
|
||||||
|
component="RiskCompliancePage",
|
||||||
|
required_any=(SANCTIONS_READ_SCOPE,),
|
||||||
|
order=115,
|
||||||
|
surface_id="risk_compliance.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/risk-compliance",
|
||||||
|
label="Risk Compliance",
|
||||||
|
icon="shield-check",
|
||||||
|
required_any=(SANCTIONS_READ_SCOPE,),
|
||||||
|
order=115,
|
||||||
|
surface_id="risk_compliance.navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.sanctions.sources",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Sanctions source snapshots",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.sanctions.screening",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Sanctions screening",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.sanctions.review",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Sanctions review queue",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(
|
||||||
|
Path(__file__).with_name("migrations") / "versions"
|
||||||
|
),
|
||||||
|
migration_after=("connectors",),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
*_PERSISTENT_MODELS,
|
||||||
|
label="Risk Compliance",
|
||||||
|
),
|
||||||
|
retirement_notes=(
|
||||||
|
"Destructive retirement removes immutable sanctions list, "
|
||||||
|
"screening, and review evidence after a database snapshot."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
*_PERSISTENT_MODELS,
|
||||||
|
label="Risk Compliance",
|
||||||
|
),
|
||||||
|
),
|
||||||
documentation=DOCUMENTATION,
|
documentation=DOCUMENTATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ADMIN_SCOPE",
|
||||||
|
"MODULE_ID",
|
||||||
|
"MODULE_VERSION",
|
||||||
|
"PERMISSIONS",
|
||||||
|
"READ_SCOPE",
|
||||||
|
"ROLE_TEMPLATES",
|
||||||
|
"SANCTIONS_ADMIN_SCOPE",
|
||||||
|
"SANCTIONS_READ_SCOPE",
|
||||||
|
"SANCTIONS_REVIEW_SCOPE",
|
||||||
|
"SANCTIONS_SCREEN_SCOPE",
|
||||||
|
"WRITE_SCOPE",
|
||||||
|
"get_manifest",
|
||||||
|
"manifest",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Risk Compliance database migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Risk Compliance Alembic revisions."""
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
"""Add sanctions catalogue, screening, and review evidence.
|
||||||
|
|
||||||
|
Revision ID: a8b9c0d1e2f3
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a8b9c0d1e2f3"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamps() -> tuple[sa.Column, sa.Column]:
|
||||||
|
return (
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"risk_sanctions_list_snapshots",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"visibility",
|
||||||
|
sa.String(length=20),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"connector_snapshot_ref",
|
||||||
|
sa.String(length=300),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"publisher",
|
||||||
|
sa.String(length=300),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"jurisdiction",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"list_type",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_version",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"publication_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"effective_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"acquired_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"connector_run_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"raw_evidence_ref",
|
||||||
|
sa.String(length=300),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_parser_version",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"normalization_version",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"signature_evidence",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("entry_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"imported_by",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"imported_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_sanctions_list_snapshots"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"connector_snapshot_ref",
|
||||||
|
name="uq_risk_sanctions_connector_snapshot",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_sanctions_list_snapshots",
|
||||||
|
(
|
||||||
|
"tenant_id",
|
||||||
|
"visibility",
|
||||||
|
"provider_id",
|
||||||
|
"jurisdiction",
|
||||||
|
"list_type",
|
||||||
|
"source_id",
|
||||||
|
"source_version",
|
||||||
|
"acquired_at",
|
||||||
|
"sha256",
|
||||||
|
"status",
|
||||||
|
"imported_by",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_sanctions_snapshot_current",
|
||||||
|
"risk_sanctions_list_snapshots",
|
||||||
|
["tenant_id", "provider_id", "status", "acquired_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_sanctions_entries",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("snapshot_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"source_entry_id",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"subject_type",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"primary_name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"normalized_name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"original_script_name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"reference_number",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("listed_on", sa.Date(), nullable=True),
|
||||||
|
sa.Column("programmes", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("measures", sa.JSON(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"raw_evidence_locator",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=False),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["snapshot_id"],
|
||||||
|
["risk_sanctions_list_snapshots.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_sanctions_entries_snapshot_id_"
|
||||||
|
"risk_sanctions_list_snapshots"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_sanctions_entries"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"snapshot_id",
|
||||||
|
"source_entry_id",
|
||||||
|
name="uq_risk_sanctions_entry_source",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_sanctions_entries",
|
||||||
|
(
|
||||||
|
"snapshot_id",
|
||||||
|
"source_entry_id",
|
||||||
|
"subject_type",
|
||||||
|
"normalized_name",
|
||||||
|
"reference_number",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_sanctions_entry_name",
|
||||||
|
"risk_sanctions_entries",
|
||||||
|
["snapshot_id", "normalized_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_sanctions_aliases",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("entry_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"normalized_name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"quality",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"alias_type",
|
||||||
|
sa.String(length=50),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["entry_id"],
|
||||||
|
["risk_sanctions_entries.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_sanctions_aliases_entry_id_"
|
||||||
|
"risk_sanctions_entries"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_sanctions_aliases"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_sanctions_aliases",
|
||||||
|
("entry_id", "normalized_name"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_sanctions_alias_name",
|
||||||
|
"risk_sanctions_aliases",
|
||||||
|
["entry_id", "normalized_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_sanctions_identifiers",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("entry_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"identifier_type",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"value",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"normalized_value",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"issuing_country",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["entry_id"],
|
||||||
|
["risk_sanctions_entries.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_sanctions_identifiers_entry_id_"
|
||||||
|
"risk_sanctions_entries"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_sanctions_identifiers"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_sanctions_identifiers",
|
||||||
|
("entry_id", "normalized_value"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_sanctions_identifier_value",
|
||||||
|
"risk_sanctions_identifiers",
|
||||||
|
["entry_id", "normalized_value"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_sanctions_dates",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("entry_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"date_type",
|
||||||
|
sa.String(length=50),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("value", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"precision",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["entry_id"],
|
||||||
|
["risk_sanctions_entries.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_sanctions_dates_entry_id_"
|
||||||
|
"risk_sanctions_entries"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_sanctions_dates"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes("risk_sanctions_dates", ("entry_id",))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_sanctions_addresses",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("entry_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"street",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("city", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("region", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"postal_code",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"country",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["entry_id"],
|
||||||
|
["risk_sanctions_entries.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_sanctions_addresses_entry_id_"
|
||||||
|
"risk_sanctions_entries"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_sanctions_addresses"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes("risk_sanctions_addresses", ("entry_id",))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_screening_subject_snapshots",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"subject_ref",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"subject_type",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"primary_name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"normalized_name",
|
||||||
|
sa.String(length=1000),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("aliases", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("identifiers", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("dates", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("addresses", sa.JSON(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"fingerprint",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"submitted_by",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_screening_subject_snapshots"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_screening_subject_snapshots",
|
||||||
|
(
|
||||||
|
"tenant_id",
|
||||||
|
"subject_ref",
|
||||||
|
"normalized_name",
|
||||||
|
"fingerprint",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_screening_runs",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"subject_snapshot_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"list_snapshot_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"idempotency_key",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"request_hash",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"matcher_version",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"normalization_version",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"policy_version",
|
||||||
|
sa.String(length=100),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("policy_snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("outcome", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("candidate_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"started_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"completed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_by",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["list_snapshot_id"],
|
||||||
|
["risk_sanctions_list_snapshots.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_runs_list_snapshot_id_"
|
||||||
|
"risk_sanctions_list_snapshots"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["subject_snapshot_id"],
|
||||||
|
["risk_screening_subject_snapshots.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_runs_subject_snapshot_id_"
|
||||||
|
"risk_screening_subject_snapshots"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_screening_runs"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_risk_screening_run_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_screening_runs",
|
||||||
|
(
|
||||||
|
"tenant_id",
|
||||||
|
"subject_snapshot_id",
|
||||||
|
"list_snapshot_id",
|
||||||
|
"status",
|
||||||
|
"outcome",
|
||||||
|
"created_by",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_screening_run_status",
|
||||||
|
"risk_screening_runs",
|
||||||
|
["tenant_id", "outcome", "created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_screening_candidates",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("entry_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("score", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"match_kind",
|
||||||
|
sa.String(length=50),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"review_status",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"current_disposition_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["entry_id"],
|
||||||
|
["risk_sanctions_entries.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_candidates_entry_id_"
|
||||||
|
"risk_sanctions_entries"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["run_id"],
|
||||||
|
["risk_screening_runs.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_candidates_run_id_"
|
||||||
|
"risk_screening_runs"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_screening_candidates"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"run_id",
|
||||||
|
"entry_id",
|
||||||
|
name="uq_risk_screening_candidate_entry",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_screening_candidates",
|
||||||
|
(
|
||||||
|
"tenant_id",
|
||||||
|
"run_id",
|
||||||
|
"entry_id",
|
||||||
|
"score",
|
||||||
|
"match_kind",
|
||||||
|
"review_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_screening_candidate_queue",
|
||||||
|
"risk_screening_candidates",
|
||||||
|
["tenant_id", "review_status", "score"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_screening_dispositions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"candidate_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"decision",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("evidence_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"expires_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"review_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"actor_account_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"actor_membership_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("actor_authority", sa.JSON(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"separation_status",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("override_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"supersedes_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["candidate_id"],
|
||||||
|
["risk_screening_candidates.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_dispositions_candidate_id_"
|
||||||
|
"risk_screening_candidates"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["supersedes_id"],
|
||||||
|
["risk_screening_dispositions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_dispositions_supersedes_id_"
|
||||||
|
"risk_screening_dispositions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_screening_dispositions"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_screening_dispositions",
|
||||||
|
(
|
||||||
|
"tenant_id",
|
||||||
|
"candidate_id",
|
||||||
|
"decision",
|
||||||
|
"actor_account_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_screening_exceptions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"subject_fingerprint",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_entry_ref",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("scope", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("evidence_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"starts_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"expires_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"review_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"originating_disposition_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_by",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
*_timestamps(),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["originating_disposition_id"],
|
||||||
|
["risk_screening_dispositions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_risk_screening_exceptions_"
|
||||||
|
"originating_disposition_id_"
|
||||||
|
"risk_screening_dispositions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_risk_screening_exceptions"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_indexes(
|
||||||
|
"risk_screening_exceptions",
|
||||||
|
(
|
||||||
|
"tenant_id",
|
||||||
|
"subject_fingerprint",
|
||||||
|
"source_entry_ref",
|
||||||
|
"status",
|
||||||
|
"expires_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_screening_exception_active",
|
||||||
|
"risk_screening_exceptions",
|
||||||
|
[
|
||||||
|
"tenant_id",
|
||||||
|
"subject_fingerprint",
|
||||||
|
"source_entry_ref",
|
||||||
|
"status",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table_name in (
|
||||||
|
"risk_screening_exceptions",
|
||||||
|
"risk_screening_dispositions",
|
||||||
|
"risk_screening_candidates",
|
||||||
|
"risk_screening_runs",
|
||||||
|
"risk_screening_subject_snapshots",
|
||||||
|
"risk_sanctions_addresses",
|
||||||
|
"risk_sanctions_dates",
|
||||||
|
"risk_sanctions_identifiers",
|
||||||
|
"risk_sanctions_aliases",
|
||||||
|
"risk_sanctions_entries",
|
||||||
|
"risk_sanctions_list_snapshots",
|
||||||
|
):
|
||||||
|
op.drop_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(
|
||||||
|
table_name: str,
|
||||||
|
columns: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
for column in columns:
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_{table_name}_{column}"),
|
||||||
|
table_name,
|
||||||
|
[column],
|
||||||
|
)
|
||||||
54
src/govoplan_risk_compliance/backend/normalization.py
Normal file
54
src/govoplan_risk_compliance/backend/normalization.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
NORMALIZATION_VERSION = "sanctions-normalization-v1"
|
||||||
|
_NON_ALNUM = re.compile(r"[^\w]+", re.UNICODE)
|
||||||
|
_WHITESPACE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(value: str | None) -> str:
|
||||||
|
clean = unicodedata.normalize("NFKD", str(value or ""))
|
||||||
|
without_marks = "".join(
|
||||||
|
character
|
||||||
|
for character in clean
|
||||||
|
if not unicodedata.combining(character)
|
||||||
|
)
|
||||||
|
folded = without_marks.casefold()
|
||||||
|
return _WHITESPACE.sub(
|
||||||
|
" ",
|
||||||
|
_NON_ALNUM.sub(" ", folded).replace("_", " "),
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_identifier(value: str | None) -> str:
|
||||||
|
return "".join(
|
||||||
|
character
|
||||||
|
for character in unicodedata.normalize(
|
||||||
|
"NFKC",
|
||||||
|
str(value or ""),
|
||||||
|
).casefold()
|
||||||
|
if character.isalnum()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def subject_fingerprint(payload: dict[str, object]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NORMALIZATION_VERSION",
|
||||||
|
"normalize_identifier",
|
||||||
|
"normalize_name",
|
||||||
|
"subject_fingerprint",
|
||||||
|
]
|
||||||
18
src/govoplan_risk_compliance/backend/permissions.py
Normal file
18
src/govoplan_risk_compliance/backend/permissions.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
READ_SCOPE = "risk_compliance:workspace:read"
|
||||||
|
WRITE_SCOPE = "risk_compliance:workspace:write"
|
||||||
|
ADMIN_SCOPE = "risk_compliance:workspace:admin"
|
||||||
|
SANCTIONS_READ_SCOPE = "risk_compliance:sanctions:read"
|
||||||
|
SANCTIONS_SCREEN_SCOPE = "risk_compliance:sanctions:screen"
|
||||||
|
SANCTIONS_REVIEW_SCOPE = "risk_compliance:sanctions:review"
|
||||||
|
SANCTIONS_ADMIN_SCOPE = "risk_compliance:sanctions:admin"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ADMIN_SCOPE",
|
||||||
|
"READ_SCOPE",
|
||||||
|
"SANCTIONS_ADMIN_SCOPE",
|
||||||
|
"SANCTIONS_READ_SCOPE",
|
||||||
|
"SANCTIONS_REVIEW_SCOPE",
|
||||||
|
"SANCTIONS_SCREEN_SCOPE",
|
||||||
|
"WRITE_SCOPE",
|
||||||
|
]
|
||||||
304
src/govoplan_risk_compliance/backend/review.py
Normal file
304
src/govoplan_risk_compliance/backend/review.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.db.base import utcnow
|
||||||
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningDisposition,
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningRun,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.permissions import (
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.sanctions_catalog import (
|
||||||
|
RiskSanctionsAccessError,
|
||||||
|
RiskSanctionsConflictError,
|
||||||
|
RiskSanctionsNotFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DispositionDecision = Literal[
|
||||||
|
"true_match",
|
||||||
|
"false_positive",
|
||||||
|
"needs_information",
|
||||||
|
"escalated",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DispositionInput:
|
||||||
|
decision: DispositionDecision
|
||||||
|
reason: str
|
||||||
|
evidence_refs: tuple[str, ...] = ()
|
||||||
|
exception_scope: Literal["candidate", "subject_entry"] = "candidate"
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
review_at: datetime | None = None
|
||||||
|
override_reason: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.decision not in {
|
||||||
|
"true_match",
|
||||||
|
"false_positive",
|
||||||
|
"needs_information",
|
||||||
|
"escalated",
|
||||||
|
}:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Unsupported screening disposition."
|
||||||
|
)
|
||||||
|
if len(self.reason.strip()) < 3:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"A disposition reason is required."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.exception_scope == "subject_entry"
|
||||||
|
and self.decision != "false_positive"
|
||||||
|
):
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Only false-positive decisions can create a reusable exception."
|
||||||
|
)
|
||||||
|
if self.exception_scope == "subject_entry":
|
||||||
|
if self.expires_at is None:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Reusable exceptions require an expiry time."
|
||||||
|
)
|
||||||
|
if _aware(self.expires_at) <= _aware(utcnow()):
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Reusable exception expiry must be in the future."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_review_queue(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
status: str = "pending",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[RiskScreeningCandidate, ...]:
|
||||||
|
_require_review(principal)
|
||||||
|
query = (
|
||||||
|
select(RiskScreeningCandidate)
|
||||||
|
.where(RiskScreeningCandidate.tenant_id == principal.tenant_id)
|
||||||
|
.options(
|
||||||
|
selectinload(RiskScreeningCandidate.run).options(
|
||||||
|
selectinload(RiskScreeningRun.subject_snapshot),
|
||||||
|
selectinload(RiskScreeningRun.list_snapshot),
|
||||||
|
),
|
||||||
|
selectinload(RiskScreeningCandidate.entry).options(
|
||||||
|
selectinload(RiskSanctionsEntry.aliases),
|
||||||
|
selectinload(RiskSanctionsEntry.identifiers),
|
||||||
|
selectinload(RiskSanctionsEntry.dates),
|
||||||
|
selectinload(RiskSanctionsEntry.addresses),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RiskScreeningCandidate.score.desc(),
|
||||||
|
RiskScreeningCandidate.created_at.asc(),
|
||||||
|
)
|
||||||
|
.limit(max(1, min(limit, 500)))
|
||||||
|
)
|
||||||
|
if status != "all":
|
||||||
|
requested = (
|
||||||
|
("pending", "exception_review")
|
||||||
|
if status == "pending"
|
||||||
|
else (status,)
|
||||||
|
)
|
||||||
|
query = query.where(
|
||||||
|
RiskScreeningCandidate.review_status.in_(requested)
|
||||||
|
)
|
||||||
|
return tuple(session.scalars(query))
|
||||||
|
|
||||||
|
|
||||||
|
def get_candidate(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
candidate_id: str,
|
||||||
|
) -> RiskScreeningCandidate:
|
||||||
|
_require_review(principal)
|
||||||
|
item = session.scalar(
|
||||||
|
select(RiskScreeningCandidate)
|
||||||
|
.where(
|
||||||
|
RiskScreeningCandidate.id == candidate_id,
|
||||||
|
RiskScreeningCandidate.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(RiskScreeningCandidate.run).options(
|
||||||
|
selectinload(RiskScreeningRun.subject_snapshot),
|
||||||
|
selectinload(RiskScreeningRun.list_snapshot),
|
||||||
|
),
|
||||||
|
selectinload(RiskScreeningCandidate.entry).options(
|
||||||
|
selectinload(RiskSanctionsEntry.aliases),
|
||||||
|
selectinload(RiskSanctionsEntry.identifiers),
|
||||||
|
selectinload(RiskSanctionsEntry.dates),
|
||||||
|
selectinload(RiskSanctionsEntry.addresses),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise RiskSanctionsNotFoundError(
|
||||||
|
"Screening candidate was not found."
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def record_disposition(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
candidate_id: str,
|
||||||
|
disposition: DispositionInput,
|
||||||
|
) -> tuple[RiskScreeningCandidate, RiskScreeningDisposition]:
|
||||||
|
candidate = get_candidate(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
)
|
||||||
|
actor_id = principal.account_id
|
||||||
|
same_actor = bool(
|
||||||
|
candidate.run.created_by
|
||||||
|
and candidate.run.created_by
|
||||||
|
in {
|
||||||
|
principal.account_id,
|
||||||
|
principal.membership_id,
|
||||||
|
principal.identity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
separation_status = "independent"
|
||||||
|
clean_override = (disposition.override_reason or "").strip()
|
||||||
|
if same_actor:
|
||||||
|
if not has_scope(principal, SANCTIONS_ADMIN_SCOPE):
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
"The screening submitter cannot review the same candidate."
|
||||||
|
)
|
||||||
|
if len(clean_override) < 3:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"An administrator override reason is required when "
|
||||||
|
"reviewing your own screening."
|
||||||
|
)
|
||||||
|
separation_status = "administrator_override"
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
item = RiskScreeningDisposition(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
candidate_id=candidate.id,
|
||||||
|
decision=disposition.decision,
|
||||||
|
reason=disposition.reason.strip(),
|
||||||
|
evidence_refs=[
|
||||||
|
value.strip()[:500]
|
||||||
|
for value in disposition.evidence_refs
|
||||||
|
if value.strip()
|
||||||
|
][:100],
|
||||||
|
scope=disposition.exception_scope,
|
||||||
|
expires_at=disposition.expires_at,
|
||||||
|
review_at=disposition.review_at,
|
||||||
|
actor_account_id=actor_id,
|
||||||
|
actor_membership_id=principal.membership_id,
|
||||||
|
actor_authority={
|
||||||
|
"required_scope": SANCTIONS_REVIEW_SCOPE,
|
||||||
|
"admin": has_scope(principal, SANCTIONS_ADMIN_SCOPE),
|
||||||
|
"auth_method": principal.auth_method,
|
||||||
|
"acting_for_account_id": principal.acting_for_account_id,
|
||||||
|
},
|
||||||
|
separation_status=separation_status,
|
||||||
|
override_reason=clean_override or None,
|
||||||
|
supersedes_id=candidate.current_disposition_id,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.flush()
|
||||||
|
candidate.current_disposition_id = item.id
|
||||||
|
candidate.review_status = {
|
||||||
|
"true_match": "confirmed",
|
||||||
|
"false_positive": "false_positive",
|
||||||
|
"needs_information": "needs_information",
|
||||||
|
"escalated": "escalated",
|
||||||
|
}[disposition.decision]
|
||||||
|
|
||||||
|
if disposition.exception_scope == "subject_entry":
|
||||||
|
session.add(
|
||||||
|
RiskScreeningException(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
subject_fingerprint=(
|
||||||
|
candidate.run.subject_snapshot.fingerprint
|
||||||
|
),
|
||||||
|
source_entry_ref=candidate.entry.source_entry_id,
|
||||||
|
scope="subject_entry",
|
||||||
|
status="active",
|
||||||
|
reason=disposition.reason.strip(),
|
||||||
|
evidence_refs=list(item.evidence_refs),
|
||||||
|
starts_at=now,
|
||||||
|
expires_at=disposition.expires_at,
|
||||||
|
review_at=disposition.review_at,
|
||||||
|
originating_disposition_id=item.id,
|
||||||
|
created_by=actor_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
return candidate, item
|
||||||
|
|
||||||
|
|
||||||
|
def list_candidate_dispositions(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
candidate_id: str,
|
||||||
|
) -> tuple[RiskScreeningDisposition, ...]:
|
||||||
|
candidate = get_candidate(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
session.scalars(
|
||||||
|
select(RiskScreeningDisposition)
|
||||||
|
.where(
|
||||||
|
RiskScreeningDisposition.candidate_id == candidate.id,
|
||||||
|
RiskScreeningDisposition.tenant_id
|
||||||
|
== principal.tenant_id,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RiskScreeningDisposition.created_at.asc(),
|
||||||
|
RiskScreeningDisposition.id.asc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_review(principal: ApiPrincipal) -> None:
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
"A tenant API principal is required."
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
has_scope(principal, SANCTIONS_REVIEW_SCOPE)
|
||||||
|
or has_scope(principal, SANCTIONS_ADMIN_SCOPE)
|
||||||
|
):
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
f"Missing scope: {SANCTIONS_REVIEW_SCOPE}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DispositionDecision",
|
||||||
|
"DispositionInput",
|
||||||
|
"get_candidate",
|
||||||
|
"list_candidate_dispositions",
|
||||||
|
"list_review_queue",
|
||||||
|
"record_disposition",
|
||||||
|
]
|
||||||
602
src/govoplan_risk_compliance/backend/router.py
Normal file
602
src/govoplan_risk_compliance/backend/router.py
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.sanctions import sanctions_snapshot_provider
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_risk_compliance.backend.review import (
|
||||||
|
DispositionInput,
|
||||||
|
get_candidate,
|
||||||
|
list_candidate_dispositions,
|
||||||
|
list_review_queue,
|
||||||
|
record_disposition,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.sanctions_catalog import (
|
||||||
|
RiskSanctionsAccessError,
|
||||||
|
RiskSanctionsConflictError,
|
||||||
|
RiskSanctionsError,
|
||||||
|
RiskSanctionsNotFoundError,
|
||||||
|
import_connector_snapshot,
|
||||||
|
list_list_snapshots,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.schemas import (
|
||||||
|
CandidateDetailResponse,
|
||||||
|
CandidateResponse,
|
||||||
|
ConnectorSnapshotListResponse,
|
||||||
|
ConnectorSnapshotResponse,
|
||||||
|
DispositionCreateRequest,
|
||||||
|
DispositionCreateResponse,
|
||||||
|
DispositionListResponse,
|
||||||
|
DispositionResponse,
|
||||||
|
ListSnapshotImportRequest,
|
||||||
|
ListSnapshotImportResponse,
|
||||||
|
ListSnapshotListResponse,
|
||||||
|
ListSnapshotResponse,
|
||||||
|
ReviewQueueItemResponse,
|
||||||
|
ReviewQueueResponse,
|
||||||
|
ScreeningRunCreateRequest,
|
||||||
|
ScreeningRunCreateResponse,
|
||||||
|
ScreeningRunListResponse,
|
||||||
|
ScreeningRunResponse,
|
||||||
|
ScreeningRunSummaryResponse,
|
||||||
|
SubjectSnapshotResponse,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.screening import (
|
||||||
|
ScreeningPolicy,
|
||||||
|
ScreeningSubject,
|
||||||
|
get_screening_run,
|
||||||
|
list_screening_runs,
|
||||||
|
run_screening,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/risk-compliance",
|
||||||
|
tags=["risk-compliance"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry(request: Request) -> object | None:
|
||||||
|
return getattr(request.app.state, "govoplan_registry", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(exc: RiskSanctionsError) -> HTTPException:
|
||||||
|
if isinstance(exc, RiskSanctionsNotFoundError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, RiskSanctionsAccessError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, RiskSanctionsConflictError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/source-snapshots",
|
||||||
|
response_model=ConnectorSnapshotListResponse,
|
||||||
|
)
|
||||||
|
def api_list_connector_snapshots(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ConnectorSnapshotListResponse:
|
||||||
|
provider = sanctions_snapshot_provider(_registry(request))
|
||||||
|
if provider is None:
|
||||||
|
return ConnectorSnapshotListResponse(
|
||||||
|
available=False,
|
||||||
|
snapshots=[],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
snapshots = provider.list_snapshots(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return ConnectorSnapshotListResponse(
|
||||||
|
available=True,
|
||||||
|
snapshots=[
|
||||||
|
ConnectorSnapshotResponse.model_validate(
|
||||||
|
{
|
||||||
|
"ref": item.ref,
|
||||||
|
"provider_id": item.provider_id,
|
||||||
|
"publisher": item.publisher,
|
||||||
|
"jurisdiction": item.jurisdiction,
|
||||||
|
"list_type": item.list_type,
|
||||||
|
"source_id": item.source_id,
|
||||||
|
"source_version": item.source_version,
|
||||||
|
"publication_at": item.publication_at,
|
||||||
|
"effective_at": item.effective_at,
|
||||||
|
"acquired_at": item.acquired_at,
|
||||||
|
"byte_count": item.byte_count,
|
||||||
|
"sha256": item.sha256,
|
||||||
|
"parser_version": item.parser_version,
|
||||||
|
"raw_evidence_ref": item.raw_evidence_ref,
|
||||||
|
"connector_run_id": item.connector_run_id,
|
||||||
|
"licence_notes": item.licence_notes,
|
||||||
|
"trust_notes": item.trust_notes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for item in snapshots
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sanctions/list-snapshots/import",
|
||||||
|
response_model=ListSnapshotImportResponse,
|
||||||
|
)
|
||||||
|
def api_import_list_snapshot(
|
||||||
|
payload: ListSnapshotImportRequest,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ListSnapshotImportResponse:
|
||||||
|
try:
|
||||||
|
item, created = import_connector_snapshot(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=_registry(request),
|
||||||
|
connector_snapshot_ref=payload.connector_snapshot_ref,
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="risk_compliance.sanctions_snapshot.imported",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="risk_sanctions_list_snapshot",
|
||||||
|
object_id=item.id,
|
||||||
|
details={
|
||||||
|
"created": created,
|
||||||
|
"provider_id": item.provider_id,
|
||||||
|
"source_version": item.source_version,
|
||||||
|
"sha256": item.sha256,
|
||||||
|
"entry_count": item.entry_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ListSnapshotImportResponse(
|
||||||
|
snapshot=_list_snapshot_response(item),
|
||||||
|
created=created,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/list-snapshots",
|
||||||
|
response_model=ListSnapshotListResponse,
|
||||||
|
)
|
||||||
|
def api_list_list_snapshots(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ListSnapshotListResponse:
|
||||||
|
try:
|
||||||
|
items = list_list_snapshots(session, principal)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return ListSnapshotListResponse(
|
||||||
|
snapshots=[
|
||||||
|
_list_snapshot_response(item)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sanctions/screenings",
|
||||||
|
response_model=ScreeningRunCreateResponse,
|
||||||
|
)
|
||||||
|
def api_run_screening(
|
||||||
|
payload: ScreeningRunCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ScreeningRunCreateResponse:
|
||||||
|
try:
|
||||||
|
item, created = run_screening(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
list_snapshot_id=payload.list_snapshot_id,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
subject=ScreeningSubject(
|
||||||
|
subject_type=payload.subject.subject_type,
|
||||||
|
subject_ref=payload.subject.subject_ref,
|
||||||
|
primary_name=payload.subject.primary_name,
|
||||||
|
aliases=tuple(payload.subject.aliases),
|
||||||
|
identifiers=tuple(
|
||||||
|
value.model_dump()
|
||||||
|
for value in payload.subject.identifiers
|
||||||
|
),
|
||||||
|
dates=tuple(payload.subject.dates),
|
||||||
|
addresses=tuple(
|
||||||
|
value.model_dump(exclude_none=True)
|
||||||
|
for value in payload.subject.addresses
|
||||||
|
),
|
||||||
|
),
|
||||||
|
policy=ScreeningPolicy(
|
||||||
|
fuzzy_threshold=payload.policy.fuzzy_threshold,
|
||||||
|
max_snapshot_age_days=(
|
||||||
|
payload.policy.max_snapshot_age_days
|
||||||
|
),
|
||||||
|
max_candidates=payload.policy.max_candidates,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="risk_compliance.sanctions_screening.completed",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="risk_screening_run",
|
||||||
|
object_id=item.id,
|
||||||
|
details={
|
||||||
|
"created": created,
|
||||||
|
"outcome": item.outcome,
|
||||||
|
"candidate_count": item.candidate_count,
|
||||||
|
"list_snapshot_id": item.list_snapshot_id,
|
||||||
|
"matcher_version": item.matcher_version,
|
||||||
|
"normalization_version": item.normalization_version,
|
||||||
|
"policy_version": item.policy_version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ScreeningRunCreateResponse(
|
||||||
|
run=_run_response(item),
|
||||||
|
created=created,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/screenings",
|
||||||
|
response_model=ScreeningRunListResponse,
|
||||||
|
)
|
||||||
|
def api_list_screenings(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ScreeningRunListResponse:
|
||||||
|
try:
|
||||||
|
items = list_screening_runs(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return ScreeningRunListResponse(
|
||||||
|
runs=[_run_summary(item) for item in items]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/screenings/{run_id}",
|
||||||
|
response_model=ScreeningRunResponse,
|
||||||
|
)
|
||||||
|
def api_get_screening(
|
||||||
|
run_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ScreeningRunResponse:
|
||||||
|
try:
|
||||||
|
item = get_screening_run(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return _run_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/review-queue",
|
||||||
|
response_model=ReviewQueueResponse,
|
||||||
|
)
|
||||||
|
def api_list_review_queue(
|
||||||
|
review_status: str = Query(
|
||||||
|
default="pending",
|
||||||
|
pattern=(
|
||||||
|
"^(pending|all|confirmed|false_positive|"
|
||||||
|
"needs_information|escalated|exception_review)$"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ReviewQueueResponse:
|
||||||
|
try:
|
||||||
|
items = list_review_queue(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
status=review_status,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return ReviewQueueResponse(
|
||||||
|
candidates=[
|
||||||
|
ReviewQueueItemResponse(
|
||||||
|
id=item.id,
|
||||||
|
run_id=item.run_id,
|
||||||
|
score=item.score,
|
||||||
|
match_kind=item.match_kind,
|
||||||
|
review_status=item.review_status,
|
||||||
|
subject_name=item.run.subject_snapshot.primary_name,
|
||||||
|
subject_type=item.run.subject_snapshot.subject_type,
|
||||||
|
entry_name=item.entry.primary_name,
|
||||||
|
source_entry_id=item.entry.source_entry_id,
|
||||||
|
list_source_version=(
|
||||||
|
item.run.list_snapshot.source_version
|
||||||
|
),
|
||||||
|
created_at=item.created_at,
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/review-queue/{candidate_id}",
|
||||||
|
response_model=CandidateDetailResponse,
|
||||||
|
)
|
||||||
|
def api_get_candidate(
|
||||||
|
candidate_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> CandidateDetailResponse:
|
||||||
|
try:
|
||||||
|
item = get_candidate(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return _candidate_detail_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sanctions/review-queue/{candidate_id}/dispositions",
|
||||||
|
response_model=DispositionCreateResponse,
|
||||||
|
)
|
||||||
|
def api_record_disposition(
|
||||||
|
candidate_id: str,
|
||||||
|
payload: DispositionCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DispositionCreateResponse:
|
||||||
|
try:
|
||||||
|
candidate, item = record_disposition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
disposition=DispositionInput(
|
||||||
|
decision=payload.decision,
|
||||||
|
reason=payload.reason,
|
||||||
|
evidence_refs=tuple(payload.evidence_refs),
|
||||||
|
exception_scope=payload.exception_scope,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
review_at=payload.review_at,
|
||||||
|
override_reason=payload.override_reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="risk_compliance.sanctions_disposition.recorded",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="risk_screening_candidate",
|
||||||
|
object_id=candidate.id,
|
||||||
|
details={
|
||||||
|
"disposition_id": item.id,
|
||||||
|
"decision": item.decision,
|
||||||
|
"scope": item.scope,
|
||||||
|
"separation_status": item.separation_status,
|
||||||
|
"supersedes_id": item.supersedes_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return DispositionCreateResponse(
|
||||||
|
candidate=_candidate_response(candidate),
|
||||||
|
disposition=_disposition_response(item),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sanctions/review-queue/{candidate_id}/dispositions",
|
||||||
|
response_model=DispositionListResponse,
|
||||||
|
)
|
||||||
|
def api_list_dispositions(
|
||||||
|
candidate_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> DispositionListResponse:
|
||||||
|
try:
|
||||||
|
items = list_candidate_dispositions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
)
|
||||||
|
except RiskSanctionsError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
return DispositionListResponse(
|
||||||
|
dispositions=[
|
||||||
|
_disposition_response(item)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_snapshot_response(item) -> ListSnapshotResponse:
|
||||||
|
return ListSnapshotResponse.model_validate(
|
||||||
|
item,
|
||||||
|
from_attributes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_response(item) -> SubjectSnapshotResponse:
|
||||||
|
return SubjectSnapshotResponse.model_validate(
|
||||||
|
item,
|
||||||
|
from_attributes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_response(item) -> dict:
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"source_entry_id": item.source_entry_id,
|
||||||
|
"subject_type": item.subject_type,
|
||||||
|
"primary_name": item.primary_name,
|
||||||
|
"normalized_name": item.normalized_name,
|
||||||
|
"original_script_name": item.original_script_name,
|
||||||
|
"reference_number": item.reference_number,
|
||||||
|
"listed_on": item.listed_on,
|
||||||
|
"programmes": list(item.programmes),
|
||||||
|
"measures": list(item.measures),
|
||||||
|
"raw_evidence_locator": item.raw_evidence_locator,
|
||||||
|
"details": dict(item.details),
|
||||||
|
"aliases": [
|
||||||
|
{
|
||||||
|
"name": value.name,
|
||||||
|
"normalized_name": value.normalized_name,
|
||||||
|
"quality": value.quality,
|
||||||
|
"alias_type": value.alias_type,
|
||||||
|
}
|
||||||
|
for value in item.aliases
|
||||||
|
],
|
||||||
|
"identifiers": [
|
||||||
|
{
|
||||||
|
"identifier_type": value.identifier_type,
|
||||||
|
"value": value.value,
|
||||||
|
"issuing_country": value.issuing_country,
|
||||||
|
"note": value.note,
|
||||||
|
}
|
||||||
|
for value in item.identifiers
|
||||||
|
],
|
||||||
|
"dates": [
|
||||||
|
{
|
||||||
|
"date_type": value.date_type,
|
||||||
|
"value": value.value,
|
||||||
|
"precision": value.precision,
|
||||||
|
}
|
||||||
|
for value in item.dates
|
||||||
|
],
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"street": value.street,
|
||||||
|
"city": value.city,
|
||||||
|
"region": value.region,
|
||||||
|
"postal_code": value.postal_code,
|
||||||
|
"country": value.country,
|
||||||
|
"note": value.note,
|
||||||
|
}
|
||||||
|
for value in item.addresses
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_response(item) -> CandidateResponse:
|
||||||
|
return CandidateResponse.model_validate(
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"score": item.score,
|
||||||
|
"match_kind": item.match_kind,
|
||||||
|
"evidence": list(item.evidence),
|
||||||
|
"review_status": item.review_status,
|
||||||
|
"current_disposition_id": item.current_disposition_id,
|
||||||
|
"entry": _entry_response(item.entry),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_summary(item) -> ScreeningRunSummaryResponse:
|
||||||
|
return ScreeningRunSummaryResponse(
|
||||||
|
id=item.id,
|
||||||
|
subject_name=item.subject_snapshot.primary_name,
|
||||||
|
subject_type=item.subject_snapshot.subject_type,
|
||||||
|
list_snapshot_id=item.list_snapshot_id,
|
||||||
|
list_source_version=item.list_snapshot.source_version,
|
||||||
|
matcher_version=item.matcher_version,
|
||||||
|
normalization_version=item.normalization_version,
|
||||||
|
policy_version=item.policy_version,
|
||||||
|
status=item.status,
|
||||||
|
outcome=item.outcome,
|
||||||
|
candidate_count=item.candidate_count,
|
||||||
|
started_at=item.started_at,
|
||||||
|
completed_at=item.completed_at,
|
||||||
|
created_at=item.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_response(item) -> ScreeningRunResponse:
|
||||||
|
return ScreeningRunResponse(
|
||||||
|
id=item.id,
|
||||||
|
idempotency_key=item.idempotency_key,
|
||||||
|
request_hash=item.request_hash,
|
||||||
|
matcher_version=item.matcher_version,
|
||||||
|
normalization_version=item.normalization_version,
|
||||||
|
policy_version=item.policy_version,
|
||||||
|
policy_snapshot=dict(item.policy_snapshot),
|
||||||
|
status=item.status,
|
||||||
|
outcome=item.outcome,
|
||||||
|
candidate_count=item.candidate_count,
|
||||||
|
started_at=item.started_at,
|
||||||
|
completed_at=item.completed_at,
|
||||||
|
created_by=item.created_by,
|
||||||
|
created_at=item.created_at,
|
||||||
|
subject=_subject_response(item.subject_snapshot),
|
||||||
|
list_snapshot=_list_snapshot_response(item.list_snapshot),
|
||||||
|
candidates=[
|
||||||
|
_candidate_response(value)
|
||||||
|
for value in sorted(
|
||||||
|
item.candidates,
|
||||||
|
key=lambda candidate: (
|
||||||
|
-candidate.score,
|
||||||
|
candidate.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_detail_response(item) -> CandidateDetailResponse:
|
||||||
|
return CandidateDetailResponse(
|
||||||
|
candidate=_candidate_response(item),
|
||||||
|
run=_run_summary(item.run),
|
||||||
|
subject=_subject_response(item.run.subject_snapshot),
|
||||||
|
list_snapshot=_list_snapshot_response(
|
||||||
|
item.run.list_snapshot
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _disposition_response(item) -> DispositionResponse:
|
||||||
|
return DispositionResponse.model_validate(
|
||||||
|
item,
|
||||||
|
from_attributes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
547
src/govoplan_risk_compliance/backend/sanctions_catalog.py
Normal file
547
src/govoplan_risk_compliance/backend/sanctions_catalog.py
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from defusedxml import ElementTree
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.sanctions import sanctions_snapshot_provider
|
||||||
|
from govoplan_core.db.base import utcnow
|
||||||
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskSanctionsAddress,
|
||||||
|
RiskSanctionsAlias,
|
||||||
|
RiskSanctionsDate,
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskSanctionsIdentifier,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.normalization import (
|
||||||
|
NORMALIZATION_VERSION,
|
||||||
|
normalize_identifier,
|
||||||
|
normalize_name,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.permissions import (
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SANCTIONS_ENTRIES = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsAccessError(RiskSanctionsError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsNotFoundError(RiskSanctionsError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSanctionsConflictError(RiskSanctionsError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def import_connector_snapshot(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
registry: object | None,
|
||||||
|
connector_snapshot_ref: str,
|
||||||
|
) -> tuple[RiskSanctionsListSnapshot, bool]:
|
||||||
|
_require_scope(principal, SANCTIONS_ADMIN_SCOPE)
|
||||||
|
existing = session.scalar(
|
||||||
|
select(RiskSanctionsListSnapshot).where(
|
||||||
|
RiskSanctionsListSnapshot.tenant_id
|
||||||
|
== principal.tenant_id,
|
||||||
|
RiskSanctionsListSnapshot.connector_snapshot_ref
|
||||||
|
== connector_snapshot_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing, False
|
||||||
|
provider = sanctions_snapshot_provider(registry)
|
||||||
|
if provider is None:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"The Connectors sanctions snapshot capability is unavailable."
|
||||||
|
)
|
||||||
|
payload = provider.read_snapshot(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
snapshot_ref=connector_snapshot_ref,
|
||||||
|
)
|
||||||
|
source = payload.snapshot
|
||||||
|
if source.tenant_id != principal.tenant_id:
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
"Sanctions snapshot belongs to another tenant."
|
||||||
|
)
|
||||||
|
parsed = _parse_un_snapshot(
|
||||||
|
payload.content,
|
||||||
|
parser_version=source.parser_version,
|
||||||
|
)
|
||||||
|
item = RiskSanctionsListSnapshot(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
visibility="tenant",
|
||||||
|
connector_snapshot_ref=source.ref,
|
||||||
|
provider_id=source.provider_id,
|
||||||
|
publisher=source.publisher,
|
||||||
|
jurisdiction=source.jurisdiction,
|
||||||
|
list_type=source.list_type,
|
||||||
|
source_id=source.source_id,
|
||||||
|
source_version=source.source_version,
|
||||||
|
publication_at=source.publication_at,
|
||||||
|
effective_at=source.effective_at,
|
||||||
|
acquired_at=source.acquired_at,
|
||||||
|
sha256=source.sha256,
|
||||||
|
connector_run_id=source.connector_run_id,
|
||||||
|
raw_evidence_ref=source.raw_evidence_ref,
|
||||||
|
source_parser_version=source.parser_version,
|
||||||
|
normalization_version=NORMALIZATION_VERSION,
|
||||||
|
signature_evidence=dict(source.signature_evidence),
|
||||||
|
provenance={
|
||||||
|
"connector_contract_version": source.contract_version,
|
||||||
|
"transport_evidence": dict(source.transport_evidence),
|
||||||
|
"licence_notes": source.licence_notes,
|
||||||
|
"trust_notes": source.trust_notes,
|
||||||
|
},
|
||||||
|
entry_count=len(parsed),
|
||||||
|
status="active",
|
||||||
|
imported_by=_actor_id(principal),
|
||||||
|
imported_at=utcnow(),
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.flush()
|
||||||
|
for parsed_entry in parsed:
|
||||||
|
entry = RiskSanctionsEntry(
|
||||||
|
snapshot_id=item.id,
|
||||||
|
source_entry_id=parsed_entry["source_entry_id"],
|
||||||
|
subject_type=parsed_entry["subject_type"],
|
||||||
|
primary_name=parsed_entry["primary_name"],
|
||||||
|
normalized_name=normalize_name(
|
||||||
|
parsed_entry["primary_name"]
|
||||||
|
),
|
||||||
|
original_script_name=parsed_entry[
|
||||||
|
"original_script_name"
|
||||||
|
],
|
||||||
|
reference_number=parsed_entry["reference_number"],
|
||||||
|
listed_on=parsed_entry["listed_on"],
|
||||||
|
programmes=parsed_entry["programmes"],
|
||||||
|
measures=parsed_entry["measures"],
|
||||||
|
raw_evidence_locator=parsed_entry[
|
||||||
|
"raw_evidence_locator"
|
||||||
|
],
|
||||||
|
details=parsed_entry["details"],
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
RiskSanctionsAlias(
|
||||||
|
entry_id=entry.id,
|
||||||
|
name=alias["name"],
|
||||||
|
normalized_name=normalize_name(alias["name"]),
|
||||||
|
quality=alias["quality"],
|
||||||
|
alias_type=alias["alias_type"],
|
||||||
|
)
|
||||||
|
for alias in parsed_entry["aliases"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
RiskSanctionsIdentifier(
|
||||||
|
entry_id=entry.id,
|
||||||
|
identifier_type=identifier["identifier_type"],
|
||||||
|
value=identifier["value"],
|
||||||
|
normalized_value=normalize_identifier(
|
||||||
|
identifier["value"]
|
||||||
|
),
|
||||||
|
issuing_country=identifier["issuing_country"],
|
||||||
|
note=identifier["note"],
|
||||||
|
)
|
||||||
|
for identifier in parsed_entry["identifiers"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
RiskSanctionsDate(
|
||||||
|
entry_id=entry.id,
|
||||||
|
date_type=value["date_type"],
|
||||||
|
value=value["value"],
|
||||||
|
precision=value["precision"],
|
||||||
|
)
|
||||||
|
for value in parsed_entry["dates"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
RiskSanctionsAddress(
|
||||||
|
entry_id=entry.id,
|
||||||
|
street=address["street"],
|
||||||
|
city=address["city"],
|
||||||
|
region=address["region"],
|
||||||
|
postal_code=address["postal_code"],
|
||||||
|
country=address["country"],
|
||||||
|
note=address["note"],
|
||||||
|
)
|
||||||
|
for address in parsed_entry["addresses"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
return item, True
|
||||||
|
|
||||||
|
|
||||||
|
def list_list_snapshots(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> tuple[RiskSanctionsListSnapshot, ...]:
|
||||||
|
_require_scope(principal, SANCTIONS_READ_SCOPE)
|
||||||
|
return tuple(
|
||||||
|
session.scalars(
|
||||||
|
select(RiskSanctionsListSnapshot)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
RiskSanctionsListSnapshot.tenant_id
|
||||||
|
== principal.tenant_id,
|
||||||
|
RiskSanctionsListSnapshot.visibility == "global",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RiskSanctionsListSnapshot.acquired_at.desc(),
|
||||||
|
RiskSanctionsListSnapshot.id.desc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_snapshot(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
snapshot_id: str,
|
||||||
|
) -> RiskSanctionsListSnapshot:
|
||||||
|
_require_scope(principal, SANCTIONS_READ_SCOPE)
|
||||||
|
item = session.scalar(
|
||||||
|
select(RiskSanctionsListSnapshot).where(
|
||||||
|
RiskSanctionsListSnapshot.id == snapshot_id,
|
||||||
|
or_(
|
||||||
|
RiskSanctionsListSnapshot.tenant_id
|
||||||
|
== principal.tenant_id,
|
||||||
|
RiskSanctionsListSnapshot.visibility == "global",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise RiskSanctionsNotFoundError(
|
||||||
|
"Sanctions list snapshot was not found."
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_un_snapshot(
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
parser_version: str,
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
if parser_version != "unsc-xml-v1":
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
f"Unsupported sanctions parser version: {parser_version}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(content)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Stored sanctions source evidence is malformed."
|
||||||
|
) from exc
|
||||||
|
if _local_name(root.tag) != "CONSOLIDATED_LIST":
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Stored sanctions evidence has an unsupported schema."
|
||||||
|
)
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
for section_name, item_name, subject_type in (
|
||||||
|
("INDIVIDUALS", "INDIVIDUAL", "person"),
|
||||||
|
("ENTITIES", "ENTITY", "entity"),
|
||||||
|
):
|
||||||
|
section = _child(root, section_name)
|
||||||
|
if section is None:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
f"Sanctions evidence is missing {section_name}."
|
||||||
|
)
|
||||||
|
for index, element in enumerate(
|
||||||
|
_children(section, item_name),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
entries.append(
|
||||||
|
_parsed_entry(
|
||||||
|
element,
|
||||||
|
subject_type=subject_type,
|
||||||
|
locator=f"{section_name}/{item_name}[{index}]",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(entries) > MAX_SANCTIONS_ENTRIES:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Sanctions source exceeds the entry safety limit."
|
||||||
|
)
|
||||||
|
return tuple(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _parsed_entry(
|
||||||
|
element,
|
||||||
|
*,
|
||||||
|
subject_type: str,
|
||||||
|
locator: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
name = " ".join(
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
_text(element, "FIRST_NAME"),
|
||||||
|
_text(element, "SECOND_NAME"),
|
||||||
|
_text(element, "THIRD_NAME"),
|
||||||
|
_text(element, "FOURTH_NAME"),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
).strip()
|
||||||
|
if not name:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
f"Sanctions entry at {locator} has no name."
|
||||||
|
)
|
||||||
|
source_entry_id = (
|
||||||
|
_text(element, "REFERENCE_NUMBER")
|
||||||
|
or _text(element, "DATAID")
|
||||||
|
)
|
||||||
|
if not source_entry_id:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
f"Sanctions entry at {locator} has no stable source ID."
|
||||||
|
)
|
||||||
|
alias_tag = (
|
||||||
|
"INDIVIDUAL_ALIAS"
|
||||||
|
if subject_type == "person"
|
||||||
|
else "ENTITY_ALIAS"
|
||||||
|
)
|
||||||
|
address_tag = (
|
||||||
|
"INDIVIDUAL_ADDRESS"
|
||||||
|
if subject_type == "person"
|
||||||
|
else "ENTITY_ADDRESS"
|
||||||
|
)
|
||||||
|
aliases = [
|
||||||
|
{
|
||||||
|
"name": alias_name,
|
||||||
|
"quality": _text(alias, "QUALITY"),
|
||||||
|
"alias_type": "aka",
|
||||||
|
}
|
||||||
|
for alias in _children(element, alias_tag)
|
||||||
|
if (alias_name := _text(alias, "ALIAS_NAME"))
|
||||||
|
and normalize_name(alias_name) != normalize_name(name)
|
||||||
|
]
|
||||||
|
identifiers = [
|
||||||
|
{
|
||||||
|
"identifier_type": (
|
||||||
|
_text(document, "TYPE_OF_DOCUMENT")
|
||||||
|
or "document"
|
||||||
|
),
|
||||||
|
"value": number,
|
||||||
|
"issuing_country": _text(
|
||||||
|
document,
|
||||||
|
"ISSUING_COUNTRY",
|
||||||
|
),
|
||||||
|
"note": _text(document, "NOTE"),
|
||||||
|
}
|
||||||
|
for document in _children(
|
||||||
|
element,
|
||||||
|
"INDIVIDUAL_DOCUMENT",
|
||||||
|
)
|
||||||
|
if (number := _text(document, "NUMBER"))
|
||||||
|
]
|
||||||
|
dates = [
|
||||||
|
_parsed_date(value)
|
||||||
|
for value in _children(
|
||||||
|
element,
|
||||||
|
"INDIVIDUAL_DATE_OF_BIRTH",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
addresses = [
|
||||||
|
{
|
||||||
|
"street": _text(address, "STREET"),
|
||||||
|
"city": _text(address, "CITY"),
|
||||||
|
"region": (
|
||||||
|
_text(address, "STATE_PROVINCE")
|
||||||
|
or _text(address, "REGION")
|
||||||
|
),
|
||||||
|
"postal_code": _text(address, "ZIP_CODE"),
|
||||||
|
"country": _text(address, "COUNTRY"),
|
||||||
|
"note": _text(address, "NOTE"),
|
||||||
|
}
|
||||||
|
for address in _children(element, address_tag)
|
||||||
|
if any(
|
||||||
|
_text(address, field)
|
||||||
|
for field in (
|
||||||
|
"STREET",
|
||||||
|
"CITY",
|
||||||
|
"STATE_PROVINCE",
|
||||||
|
"REGION",
|
||||||
|
"ZIP_CODE",
|
||||||
|
"COUNTRY",
|
||||||
|
"NOTE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
programme = _text(element, "UN_LIST_TYPE")
|
||||||
|
return {
|
||||||
|
"source_entry_id": source_entry_id,
|
||||||
|
"subject_type": subject_type,
|
||||||
|
"primary_name": name,
|
||||||
|
"original_script_name": _text(
|
||||||
|
element,
|
||||||
|
"NAME_ORIGINAL_SCRIPT",
|
||||||
|
),
|
||||||
|
"reference_number": _text(
|
||||||
|
element,
|
||||||
|
"REFERENCE_NUMBER",
|
||||||
|
),
|
||||||
|
"listed_on": _parse_date(_text(element, "LISTED_ON")),
|
||||||
|
"programmes": [programme] if programme else [],
|
||||||
|
"measures": [],
|
||||||
|
"raw_evidence_locator": locator,
|
||||||
|
"details": {
|
||||||
|
"titles": _texts(element, "TITLE/VALUE"),
|
||||||
|
"designations": _texts(
|
||||||
|
element,
|
||||||
|
"DESIGNATION/VALUE",
|
||||||
|
),
|
||||||
|
"nationalities": _texts(
|
||||||
|
element,
|
||||||
|
"NATIONALITY/VALUE",
|
||||||
|
),
|
||||||
|
"source_data_id": _text(element, "DATAID"),
|
||||||
|
"source_version": _text(element, "VERSIONNUM"),
|
||||||
|
},
|
||||||
|
"aliases": aliases,
|
||||||
|
"identifiers": identifiers,
|
||||||
|
"dates": dates,
|
||||||
|
"addresses": addresses,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parsed_date(element) -> dict[str, str]:
|
||||||
|
exact = _text(element, "DATE")
|
||||||
|
if exact:
|
||||||
|
return {
|
||||||
|
"date_type": "birth",
|
||||||
|
"value": exact,
|
||||||
|
"precision": "day",
|
||||||
|
}
|
||||||
|
year = _text(element, "YEAR")
|
||||||
|
if year:
|
||||||
|
return {
|
||||||
|
"date_type": "birth",
|
||||||
|
"value": year,
|
||||||
|
"precision": "year",
|
||||||
|
}
|
||||||
|
from_year = _text(element, "FROM_YEAR")
|
||||||
|
to_year = _text(element, "TO_YEAR")
|
||||||
|
return {
|
||||||
|
"date_type": "birth",
|
||||||
|
"value": "-".join(
|
||||||
|
value
|
||||||
|
for value in (from_year, to_year)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
or "unknown",
|
||||||
|
"precision": "range",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _child(element, name: str):
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
child
|
||||||
|
for child in element
|
||||||
|
if _local_name(child.tag) == name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _children(element, name: str):
|
||||||
|
return tuple(
|
||||||
|
child
|
||||||
|
for child in element
|
||||||
|
if _local_name(child.tag) == name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(element, path: str) -> str | None:
|
||||||
|
current = element
|
||||||
|
for name in path.split("/"):
|
||||||
|
current = _child(current, name)
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
clean = " ".join(str(current.text or "").split())
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(element, path: str) -> list[str]:
|
||||||
|
parent_name, child_name = path.split("/", 1)
|
||||||
|
parent = _child(element, parent_name)
|
||||||
|
if parent is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
value
|
||||||
|
for item in _children(parent, child_name)
|
||||||
|
if (value := " ".join(str(item.text or "").split()))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str | None) -> date | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _local_name(value: str) -> str:
|
||||||
|
return value.rsplit("}", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_scope(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
scope: str,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
"A tenant API principal is required."
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
has_scope(principal, scope)
|
||||||
|
or has_scope(principal, SANCTIONS_ADMIN_SCOPE)
|
||||||
|
):
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
f"Missing scope: {scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_id(principal: ApiPrincipal) -> str | None:
|
||||||
|
return (
|
||||||
|
principal.account_id
|
||||||
|
or principal.membership_id
|
||||||
|
or principal.identity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_SANCTIONS_ENTRIES",
|
||||||
|
"RiskSanctionsAccessError",
|
||||||
|
"RiskSanctionsConflictError",
|
||||||
|
"RiskSanctionsError",
|
||||||
|
"RiskSanctionsNotFoundError",
|
||||||
|
"get_list_snapshot",
|
||||||
|
"import_connector_snapshot",
|
||||||
|
"list_list_snapshots",
|
||||||
|
]
|
||||||
340
src/govoplan_risk_compliance/backend/schemas.py
Normal file
340
src/govoplan_risk_compliance/backend/schemas.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorSnapshotResponse(BaseModel):
|
||||||
|
ref: 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
|
||||||
|
byte_count: int
|
||||||
|
sha256: str
|
||||||
|
parser_version: str
|
||||||
|
raw_evidence_ref: str
|
||||||
|
connector_run_id: str
|
||||||
|
licence_notes: str | None = None
|
||||||
|
trust_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorSnapshotListResponse(BaseModel):
|
||||||
|
available: bool
|
||||||
|
snapshots: list[ConnectorSnapshotResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshotImportRequest(BaseModel):
|
||||||
|
connector_snapshot_ref: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshotResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
visibility: 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
|
||||||
|
sha256: str
|
||||||
|
connector_run_id: str
|
||||||
|
raw_evidence_ref: str
|
||||||
|
source_parser_version: str
|
||||||
|
normalization_version: str
|
||||||
|
signature_evidence: dict[str, Any]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
entry_count: int
|
||||||
|
status: str
|
||||||
|
imported_by: str | None
|
||||||
|
imported_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshotImportResponse(BaseModel):
|
||||||
|
snapshot: ListSnapshotResponse
|
||||||
|
created: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshotListResponse(BaseModel):
|
||||||
|
snapshots: list[ListSnapshotResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningIdentifierInput(BaseModel):
|
||||||
|
type: str = Field(default="document", max_length=100)
|
||||||
|
value: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningAddressInput(BaseModel):
|
||||||
|
street: str | None = Field(default=None, max_length=1000)
|
||||||
|
city: str | None = Field(default=None, max_length=500)
|
||||||
|
region: str | None = Field(default=None, max_length=500)
|
||||||
|
postal_code: str | None = Field(default=None, max_length=100)
|
||||||
|
country: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningSubjectInput(BaseModel):
|
||||||
|
subject_type: Literal["person", "entity"]
|
||||||
|
subject_ref: str | None = Field(default=None, max_length=500)
|
||||||
|
primary_name: str | None = Field(default=None, max_length=1000)
|
||||||
|
aliases: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
identifiers: list[ScreeningIdentifierInput] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
dates: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
addresses: list[ScreeningAddressInput] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_lookup_fields(self) -> "ScreeningSubjectInput":
|
||||||
|
if not str(self.primary_name or "").strip() and not self.identifiers:
|
||||||
|
raise ValueError("A name or identifier is required.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningPolicyInput(BaseModel):
|
||||||
|
fuzzy_threshold: float = Field(default=0.88, ge=0.8, le=1)
|
||||||
|
max_snapshot_age_days: int = Field(default=7, ge=1, le=365)
|
||||||
|
max_candidates: int = Field(default=100, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningRunCreateRequest(BaseModel):
|
||||||
|
list_snapshot_id: str = Field(min_length=1, max_length=36)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
subject: ScreeningSubjectInput
|
||||||
|
policy: ScreeningPolicyInput = Field(
|
||||||
|
default_factory=ScreeningPolicyInput
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectSnapshotResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
subject_ref: str | None
|
||||||
|
subject_type: str
|
||||||
|
primary_name: str | None
|
||||||
|
normalized_name: str | None
|
||||||
|
aliases: list[str]
|
||||||
|
identifiers: list[dict[str, str]]
|
||||||
|
dates: list[str]
|
||||||
|
addresses: list[dict[str, str]]
|
||||||
|
fingerprint: str
|
||||||
|
submitted_by: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SanctionsAliasResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
normalized_name: str
|
||||||
|
quality: str | None
|
||||||
|
alias_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class SanctionsIdentifierResponse(BaseModel):
|
||||||
|
identifier_type: str
|
||||||
|
value: str
|
||||||
|
issuing_country: str | None
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SanctionsDateResponse(BaseModel):
|
||||||
|
date_type: str
|
||||||
|
value: str
|
||||||
|
precision: str
|
||||||
|
|
||||||
|
|
||||||
|
class SanctionsAddressResponse(BaseModel):
|
||||||
|
street: str | None
|
||||||
|
city: str | None
|
||||||
|
region: str | None
|
||||||
|
postal_code: str | None
|
||||||
|
country: str | None
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SanctionsEntryResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
source_entry_id: str
|
||||||
|
subject_type: str
|
||||||
|
primary_name: str
|
||||||
|
normalized_name: str
|
||||||
|
original_script_name: str | None
|
||||||
|
reference_number: str | None
|
||||||
|
listed_on: date | None
|
||||||
|
programmes: list[str]
|
||||||
|
measures: list[str]
|
||||||
|
raw_evidence_locator: str
|
||||||
|
details: dict[str, Any]
|
||||||
|
aliases: list[SanctionsAliasResponse]
|
||||||
|
identifiers: list[SanctionsIdentifierResponse]
|
||||||
|
dates: list[SanctionsDateResponse]
|
||||||
|
addresses: list[SanctionsAddressResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
score: int
|
||||||
|
match_kind: str
|
||||||
|
evidence: list[dict[str, Any]]
|
||||||
|
review_status: str
|
||||||
|
current_disposition_id: str | None
|
||||||
|
entry: SanctionsEntryResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningRunSummaryResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
subject_name: str | None
|
||||||
|
subject_type: str
|
||||||
|
list_snapshot_id: str
|
||||||
|
list_source_version: str
|
||||||
|
matcher_version: str
|
||||||
|
normalization_version: str
|
||||||
|
policy_version: str
|
||||||
|
status: str
|
||||||
|
outcome: str
|
||||||
|
candidate_count: int
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningRunResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
idempotency_key: str
|
||||||
|
request_hash: str
|
||||||
|
matcher_version: str
|
||||||
|
normalization_version: str
|
||||||
|
policy_version: str
|
||||||
|
policy_snapshot: dict[str, Any]
|
||||||
|
status: str
|
||||||
|
outcome: str
|
||||||
|
candidate_count: int
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime | None
|
||||||
|
created_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
subject: SubjectSnapshotResponse
|
||||||
|
list_snapshot: ListSnapshotResponse
|
||||||
|
candidates: list[CandidateResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningRunCreateResponse(BaseModel):
|
||||||
|
run: ScreeningRunResponse
|
||||||
|
created: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ScreeningRunListResponse(BaseModel):
|
||||||
|
runs: list[ScreeningRunSummaryResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewQueueItemResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
run_id: str
|
||||||
|
score: int
|
||||||
|
match_kind: str
|
||||||
|
review_status: str
|
||||||
|
subject_name: str | None
|
||||||
|
subject_type: str
|
||||||
|
entry_name: str
|
||||||
|
source_entry_id: str
|
||||||
|
list_source_version: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewQueueResponse(BaseModel):
|
||||||
|
candidates: list[ReviewQueueItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateDetailResponse(BaseModel):
|
||||||
|
candidate: CandidateResponse
|
||||||
|
run: ScreeningRunSummaryResponse
|
||||||
|
subject: SubjectSnapshotResponse
|
||||||
|
list_snapshot: ListSnapshotResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DispositionCreateRequest(BaseModel):
|
||||||
|
decision: Literal[
|
||||||
|
"true_match",
|
||||||
|
"false_positive",
|
||||||
|
"needs_information",
|
||||||
|
"escalated",
|
||||||
|
]
|
||||||
|
reason: str = Field(min_length=3, max_length=10_000)
|
||||||
|
evidence_refs: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
exception_scope: Literal[
|
||||||
|
"candidate",
|
||||||
|
"subject_entry",
|
||||||
|
] = "candidate"
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
review_at: datetime | None = None
|
||||||
|
override_reason: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=10_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DispositionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
candidate_id: str
|
||||||
|
decision: str
|
||||||
|
reason: str
|
||||||
|
evidence_refs: list[str]
|
||||||
|
scope: str
|
||||||
|
expires_at: datetime | None
|
||||||
|
review_at: datetime | None
|
||||||
|
actor_account_id: str | None
|
||||||
|
actor_membership_id: str | None
|
||||||
|
actor_authority: dict[str, Any]
|
||||||
|
separation_status: str
|
||||||
|
override_reason: str | None
|
||||||
|
supersedes_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DispositionCreateResponse(BaseModel):
|
||||||
|
candidate: CandidateResponse
|
||||||
|
disposition: DispositionResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DispositionListResponse(BaseModel):
|
||||||
|
dispositions: list[DispositionResponse]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CandidateDetailResponse",
|
||||||
|
"CandidateResponse",
|
||||||
|
"ConnectorSnapshotListResponse",
|
||||||
|
"ConnectorSnapshotResponse",
|
||||||
|
"DispositionCreateRequest",
|
||||||
|
"DispositionCreateResponse",
|
||||||
|
"DispositionListResponse",
|
||||||
|
"DispositionResponse",
|
||||||
|
"ListSnapshotImportRequest",
|
||||||
|
"ListSnapshotImportResponse",
|
||||||
|
"ListSnapshotListResponse",
|
||||||
|
"ListSnapshotResponse",
|
||||||
|
"ReviewQueueItemResponse",
|
||||||
|
"ReviewQueueResponse",
|
||||||
|
"ScreeningRunCreateRequest",
|
||||||
|
"ScreeningRunCreateResponse",
|
||||||
|
"ScreeningRunListResponse",
|
||||||
|
"ScreeningRunResponse",
|
||||||
|
"ScreeningRunSummaryResponse",
|
||||||
|
"SubjectSnapshotResponse",
|
||||||
|
]
|
||||||
648
src/govoplan_risk_compliance/backend/screening.py
Normal file
648
src/govoplan_risk_compliance/backend/screening.py
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.db.base import utcnow
|
||||||
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningRun,
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.normalization import (
|
||||||
|
NORMALIZATION_VERSION,
|
||||||
|
normalize_identifier,
|
||||||
|
normalize_name,
|
||||||
|
subject_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.permissions import (
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.sanctions_catalog import (
|
||||||
|
RiskSanctionsAccessError,
|
||||||
|
RiskSanctionsConflictError,
|
||||||
|
RiskSanctionsNotFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MATCHER_VERSION = "sanctions-matcher-v1"
|
||||||
|
POLICY_VERSION = "sanctions-policy-v1"
|
||||||
|
MAX_MATCH_ENTRIES = 5_000
|
||||||
|
MAX_CANDIDATES = 100
|
||||||
|
DEFAULT_FUZZY_THRESHOLD = 0.88
|
||||||
|
DEFAULT_MAX_SNAPSHOT_AGE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScreeningSubject:
|
||||||
|
subject_type: str
|
||||||
|
primary_name: str | None = None
|
||||||
|
subject_ref: str | None = None
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
identifiers: tuple[dict[str, str], ...] = ()
|
||||||
|
dates: tuple[str, ...] = ()
|
||||||
|
addresses: tuple[dict[str, str], ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.subject_type not in {"person", "entity"}:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Screening subject type must be person or entity."
|
||||||
|
)
|
||||||
|
if not normalize_name(self.primary_name) and not any(
|
||||||
|
normalize_identifier(item.get("value"))
|
||||||
|
for item in self.identifiers
|
||||||
|
):
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"A screening subject needs a name or identifier."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScreeningPolicy:
|
||||||
|
fuzzy_threshold: float = DEFAULT_FUZZY_THRESHOLD
|
||||||
|
max_snapshot_age_days: int = DEFAULT_MAX_SNAPSHOT_AGE_DAYS
|
||||||
|
max_candidates: int = MAX_CANDIDATES
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.8 <= self.fuzzy_threshold <= 1:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Fuzzy threshold must be between 0.8 and 1."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_snapshot_age_days <= 365:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Snapshot age must be between 1 and 365 days."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_candidates <= MAX_CANDIDATES:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
f"Candidate limit must be between 1 and {MAX_CANDIDATES}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"fuzzy_threshold": self.fuzzy_threshold,
|
||||||
|
"max_snapshot_age_days": self.max_snapshot_age_days,
|
||||||
|
"max_candidates": self.max_candidates,
|
||||||
|
"fuzzy_auto_confirms": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_screening(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
list_snapshot_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
subject: ScreeningSubject,
|
||||||
|
policy: ScreeningPolicy | None = None,
|
||||||
|
) -> tuple[RiskScreeningRun, bool]:
|
||||||
|
_require_scope(principal, SANCTIONS_SCREEN_SCOPE)
|
||||||
|
clean_key = idempotency_key.strip()
|
||||||
|
if not clean_key or len(clean_key) > 255:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Idempotency key must contain 1 to 255 characters."
|
||||||
|
)
|
||||||
|
effective_policy = policy or ScreeningPolicy()
|
||||||
|
canonical_subject = _canonical_subject(subject)
|
||||||
|
request_hash = _request_hash(
|
||||||
|
list_snapshot_id=list_snapshot_id,
|
||||||
|
subject=canonical_subject,
|
||||||
|
policy=effective_policy,
|
||||||
|
)
|
||||||
|
existing = session.scalar(
|
||||||
|
select(RiskScreeningRun).where(
|
||||||
|
RiskScreeningRun.tenant_id == principal.tenant_id,
|
||||||
|
RiskScreeningRun.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.request_hash != request_hash:
|
||||||
|
raise RiskSanctionsConflictError(
|
||||||
|
"Idempotency key was already used for another screening request."
|
||||||
|
)
|
||||||
|
return get_screening_run(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run_id=existing.id,
|
||||||
|
), False
|
||||||
|
|
||||||
|
list_snapshot = _visible_list_snapshot(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
snapshot_id=list_snapshot_id,
|
||||||
|
)
|
||||||
|
actor_id = _actor_id(principal)
|
||||||
|
subject_snapshot = RiskScreeningSubjectSnapshot(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
subject_ref=_bounded(subject.subject_ref, 500),
|
||||||
|
subject_type=subject.subject_type,
|
||||||
|
primary_name=_bounded(subject.primary_name, 1000),
|
||||||
|
normalized_name=canonical_subject["primary_name"],
|
||||||
|
aliases=list(canonical_subject["aliases"]),
|
||||||
|
identifiers=list(canonical_subject["identifiers"]),
|
||||||
|
dates=list(canonical_subject["dates"]),
|
||||||
|
addresses=list(canonical_subject["addresses"]),
|
||||||
|
fingerprint=subject_fingerprint(canonical_subject),
|
||||||
|
submitted_by=actor_id,
|
||||||
|
)
|
||||||
|
now = utcnow()
|
||||||
|
run = RiskScreeningRun(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
subject_snapshot=subject_snapshot,
|
||||||
|
list_snapshot_id=list_snapshot.id,
|
||||||
|
idempotency_key=clean_key,
|
||||||
|
request_hash=request_hash,
|
||||||
|
matcher_version=MATCHER_VERSION,
|
||||||
|
normalization_version=NORMALIZATION_VERSION,
|
||||||
|
policy_version=POLICY_VERSION,
|
||||||
|
policy_snapshot=effective_policy.to_dict(),
|
||||||
|
status="running",
|
||||||
|
outcome="insufficient",
|
||||||
|
candidate_count=0,
|
||||||
|
started_at=now,
|
||||||
|
created_by=actor_id,
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
list_state = _list_state(
|
||||||
|
list_snapshot,
|
||||||
|
now=now,
|
||||||
|
max_age_days=effective_policy.max_snapshot_age_days,
|
||||||
|
)
|
||||||
|
if list_state in {"unavailable", "stale"}:
|
||||||
|
run.status = "completed"
|
||||||
|
run.outcome = list_state
|
||||||
|
run.completed_at = utcnow()
|
||||||
|
session.flush()
|
||||||
|
return get_screening_run(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run_id=run.id,
|
||||||
|
), True
|
||||||
|
|
||||||
|
entries = tuple(
|
||||||
|
session.scalars(
|
||||||
|
select(RiskSanctionsEntry)
|
||||||
|
.where(RiskSanctionsEntry.snapshot_id == list_snapshot.id)
|
||||||
|
.options(
|
||||||
|
selectinload(RiskSanctionsEntry.aliases),
|
||||||
|
selectinload(RiskSanctionsEntry.identifiers),
|
||||||
|
selectinload(RiskSanctionsEntry.dates),
|
||||||
|
selectinload(RiskSanctionsEntry.addresses),
|
||||||
|
)
|
||||||
|
.limit(MAX_MATCH_ENTRIES + 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(entries) > MAX_MATCH_ENTRIES:
|
||||||
|
run.status = "completed"
|
||||||
|
run.outcome = "unavailable"
|
||||||
|
run.policy_snapshot = {
|
||||||
|
**run.policy_snapshot,
|
||||||
|
"failure": "entry_limit_exceeded",
|
||||||
|
}
|
||||||
|
run.completed_at = utcnow()
|
||||||
|
session.flush()
|
||||||
|
return get_screening_run(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run_id=run.id,
|
||||||
|
), True
|
||||||
|
|
||||||
|
candidates = sorted(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for entry in entries
|
||||||
|
if (
|
||||||
|
candidate := _match_entry(
|
||||||
|
subject_snapshot,
|
||||||
|
entry,
|
||||||
|
fuzzy_threshold=effective_policy.fuzzy_threshold,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
),
|
||||||
|
key=lambda item: (-item["score"], item["entry"].source_entry_id),
|
||||||
|
)[: effective_policy.max_candidates]
|
||||||
|
active_exceptions = _active_exceptions(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
subject_fingerprint_value=subject_snapshot.fingerprint,
|
||||||
|
entry_refs={
|
||||||
|
item["entry"].source_entry_id
|
||||||
|
for item in candidates
|
||||||
|
},
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
for item in candidates:
|
||||||
|
entry = item["entry"]
|
||||||
|
evidence = list(item["evidence"])
|
||||||
|
exception = active_exceptions.get(entry.source_entry_id)
|
||||||
|
review_status = "pending"
|
||||||
|
if exception is not None:
|
||||||
|
review_status = "exception_review"
|
||||||
|
evidence.append(
|
||||||
|
{
|
||||||
|
"kind": "prior_exception",
|
||||||
|
"exception_id": exception.id,
|
||||||
|
"expires_at": exception.expires_at.isoformat(),
|
||||||
|
"effect": "review_required",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
RiskScreeningCandidate(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
run_id=run.id,
|
||||||
|
entry_id=entry.id,
|
||||||
|
score=item["score"],
|
||||||
|
match_kind=item["match_kind"],
|
||||||
|
evidence=evidence,
|
||||||
|
review_status=review_status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run.candidate_count = len(candidates)
|
||||||
|
run.status = "completed"
|
||||||
|
run.outcome = "potential" if candidates else "clear"
|
||||||
|
run.completed_at = utcnow()
|
||||||
|
session.flush()
|
||||||
|
return get_screening_run(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run_id=run.id,
|
||||||
|
), True
|
||||||
|
|
||||||
|
|
||||||
|
def get_screening_run(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
) -> RiskScreeningRun:
|
||||||
|
_require_scope(principal, SANCTIONS_READ_SCOPE)
|
||||||
|
item = session.scalar(
|
||||||
|
select(RiskScreeningRun)
|
||||||
|
.where(
|
||||||
|
RiskScreeningRun.id == run_id,
|
||||||
|
RiskScreeningRun.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(RiskScreeningRun.subject_snapshot),
|
||||||
|
selectinload(RiskScreeningRun.list_snapshot),
|
||||||
|
selectinload(RiskScreeningRun.candidates).options(
|
||||||
|
selectinload(RiskScreeningCandidate.entry).options(
|
||||||
|
selectinload(RiskSanctionsEntry.aliases),
|
||||||
|
selectinload(RiskSanctionsEntry.identifiers),
|
||||||
|
selectinload(RiskSanctionsEntry.dates),
|
||||||
|
selectinload(RiskSanctionsEntry.addresses),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise RiskSanctionsNotFoundError(
|
||||||
|
"Screening run was not found."
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def list_screening_runs(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[RiskScreeningRun, ...]:
|
||||||
|
_require_scope(principal, SANCTIONS_READ_SCOPE)
|
||||||
|
return tuple(
|
||||||
|
session.scalars(
|
||||||
|
select(RiskScreeningRun)
|
||||||
|
.where(RiskScreeningRun.tenant_id == principal.tenant_id)
|
||||||
|
.options(
|
||||||
|
selectinload(RiskScreeningRun.subject_snapshot),
|
||||||
|
selectinload(RiskScreeningRun.list_snapshot),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RiskScreeningRun.created_at.desc(),
|
||||||
|
RiskScreeningRun.id.desc(),
|
||||||
|
)
|
||||||
|
.limit(max(1, min(limit, 500)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_list_snapshot(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
snapshot_id: str,
|
||||||
|
) -> RiskSanctionsListSnapshot:
|
||||||
|
item = session.scalar(
|
||||||
|
select(RiskSanctionsListSnapshot).where(
|
||||||
|
RiskSanctionsListSnapshot.id == snapshot_id,
|
||||||
|
or_(
|
||||||
|
RiskSanctionsListSnapshot.tenant_id
|
||||||
|
== principal.tenant_id,
|
||||||
|
RiskSanctionsListSnapshot.visibility == "global",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise RiskSanctionsNotFoundError(
|
||||||
|
"Sanctions list snapshot was not found."
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_subject(subject: ScreeningSubject) -> dict[str, Any]:
|
||||||
|
aliases = sorted(
|
||||||
|
{
|
||||||
|
normalized
|
||||||
|
for value in subject.aliases
|
||||||
|
if (normalized := normalize_name(value))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
identifiers = sorted(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"type": _bounded(
|
||||||
|
item.get("type") or item.get("identifier_type"),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
or "document",
|
||||||
|
"value": normalized,
|
||||||
|
}
|
||||||
|
for item in subject.identifiers
|
||||||
|
if (
|
||||||
|
normalized := normalize_identifier(item.get("value"))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
key=lambda item: (item["type"], item["value"]),
|
||||||
|
)
|
||||||
|
dates = sorted(
|
||||||
|
{
|
||||||
|
str(value).strip()[:100]
|
||||||
|
for value in subject.dates
|
||||||
|
if str(value).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
addresses = sorted(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
key: clean
|
||||||
|
for key in (
|
||||||
|
"street",
|
||||||
|
"city",
|
||||||
|
"region",
|
||||||
|
"postal_code",
|
||||||
|
"country",
|
||||||
|
)
|
||||||
|
if (clean := normalize_name(item.get(key)))
|
||||||
|
}
|
||||||
|
for item in subject.addresses
|
||||||
|
),
|
||||||
|
key=lambda item: json.dumps(item, sort_keys=True),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"subject_type": subject.subject_type,
|
||||||
|
"primary_name": normalize_name(subject.primary_name),
|
||||||
|
"aliases": aliases,
|
||||||
|
"identifiers": identifiers,
|
||||||
|
"dates": dates,
|
||||||
|
"addresses": addresses,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_hash(
|
||||||
|
*,
|
||||||
|
list_snapshot_id: str,
|
||||||
|
subject: dict[str, Any],
|
||||||
|
policy: ScreeningPolicy,
|
||||||
|
) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
{
|
||||||
|
"list_snapshot_id": list_snapshot_id,
|
||||||
|
"subject": subject,
|
||||||
|
"policy": policy.to_dict(),
|
||||||
|
"matcher_version": MATCHER_VERSION,
|
||||||
|
"normalization_version": NORMALIZATION_VERSION,
|
||||||
|
"policy_version": POLICY_VERSION,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _match_entry(
|
||||||
|
subject: RiskScreeningSubjectSnapshot,
|
||||||
|
entry: RiskSanctionsEntry,
|
||||||
|
*,
|
||||||
|
fuzzy_threshold: float,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if subject.subject_type != entry.subject_type:
|
||||||
|
return None
|
||||||
|
subject_names = {
|
||||||
|
name
|
||||||
|
for name in (
|
||||||
|
subject.normalized_name,
|
||||||
|
*(normalize_name(value) for value in subject.aliases),
|
||||||
|
)
|
||||||
|
if name
|
||||||
|
}
|
||||||
|
entry_names = {
|
||||||
|
("primary_name", entry.normalized_name),
|
||||||
|
*(
|
||||||
|
("alias", alias.normalized_name)
|
||||||
|
for alias in entry.aliases
|
||||||
|
if alias.normalized_name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
subject_identifiers = {
|
||||||
|
item.get("value", "")
|
||||||
|
for item in subject.identifiers
|
||||||
|
if item.get("value")
|
||||||
|
}
|
||||||
|
entry_identifiers = {
|
||||||
|
item.normalized_value
|
||||||
|
for item in entry.identifiers
|
||||||
|
if item.normalized_value
|
||||||
|
}
|
||||||
|
identifier_matches = sorted(
|
||||||
|
subject_identifiers & entry_identifiers
|
||||||
|
)
|
||||||
|
if identifier_matches:
|
||||||
|
return {
|
||||||
|
"entry": entry,
|
||||||
|
"score": 100,
|
||||||
|
"match_kind": "identifier_exact",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "identifier_exact",
|
||||||
|
"subject_field": "identifiers",
|
||||||
|
"list_field": "identifiers",
|
||||||
|
"source_entry_ref": entry.source_entry_id,
|
||||||
|
"raw_evidence_locator": entry.raw_evidence_locator,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
exact = next(
|
||||||
|
(
|
||||||
|
field
|
||||||
|
for field, entry_name in entry_names
|
||||||
|
if entry_name in subject_names
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if exact is not None:
|
||||||
|
score = 98 if exact == "alias" else 100
|
||||||
|
return {
|
||||||
|
"entry": entry,
|
||||||
|
"score": score,
|
||||||
|
"match_kind": (
|
||||||
|
"alias_normalized"
|
||||||
|
if exact == "alias"
|
||||||
|
else "name_normalized"
|
||||||
|
),
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "normalized_name",
|
||||||
|
"subject_field": "name",
|
||||||
|
"list_field": exact,
|
||||||
|
"source_entry_ref": entry.source_entry_id,
|
||||||
|
"raw_evidence_locator": entry.raw_evidence_locator,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
best: tuple[float, str] | None = None
|
||||||
|
for subject_name in subject_names:
|
||||||
|
if len(subject_name) < 5:
|
||||||
|
continue
|
||||||
|
for field, entry_name in entry_names:
|
||||||
|
if len(entry_name) < 5:
|
||||||
|
continue
|
||||||
|
ratio = SequenceMatcher(
|
||||||
|
None,
|
||||||
|
subject_name,
|
||||||
|
entry_name,
|
||||||
|
autojunk=False,
|
||||||
|
).ratio()
|
||||||
|
if best is None or ratio > best[0]:
|
||||||
|
best = (ratio, field)
|
||||||
|
if best is None or best[0] < fuzzy_threshold:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"entry": entry,
|
||||||
|
"score": int(round(best[0] * 100)),
|
||||||
|
"match_kind": "name_fuzzy",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "fuzzy_name",
|
||||||
|
"subject_field": "name",
|
||||||
|
"list_field": best[1],
|
||||||
|
"similarity": round(best[0], 4),
|
||||||
|
"threshold": fuzzy_threshold,
|
||||||
|
"auto_confirmed": False,
|
||||||
|
"source_entry_ref": entry.source_entry_id,
|
||||||
|
"raw_evidence_locator": entry.raw_evidence_locator,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _active_exceptions(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject_fingerprint_value: str,
|
||||||
|
entry_refs: set[str],
|
||||||
|
now: datetime,
|
||||||
|
) -> dict[str, RiskScreeningException]:
|
||||||
|
if not entry_refs:
|
||||||
|
return {}
|
||||||
|
items = session.scalars(
|
||||||
|
select(RiskScreeningException).where(
|
||||||
|
RiskScreeningException.tenant_id == tenant_id,
|
||||||
|
RiskScreeningException.subject_fingerprint
|
||||||
|
== subject_fingerprint_value,
|
||||||
|
RiskScreeningException.source_entry_ref.in_(entry_refs),
|
||||||
|
RiskScreeningException.status == "active",
|
||||||
|
RiskScreeningException.starts_at <= now,
|
||||||
|
RiskScreeningException.expires_at > now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {item.source_entry_ref: item for item in items}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_state(
|
||||||
|
snapshot: RiskSanctionsListSnapshot,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
max_age_days: int,
|
||||||
|
) -> str:
|
||||||
|
if snapshot.status != "active":
|
||||||
|
return "unavailable"
|
||||||
|
acquired_at = snapshot.acquired_at
|
||||||
|
if acquired_at.tzinfo is None:
|
||||||
|
acquired_at = acquired_at.replace(tzinfo=timezone.utc)
|
||||||
|
compare_now = now
|
||||||
|
if compare_now.tzinfo is None:
|
||||||
|
compare_now = compare_now.replace(tzinfo=timezone.utc)
|
||||||
|
if compare_now - acquired_at > timedelta(days=max_age_days):
|
||||||
|
return "stale"
|
||||||
|
return "active"
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded(value: object, length: int) -> str | None:
|
||||||
|
clean = " ".join(str(value or "").split())
|
||||||
|
return clean[:length] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_scope(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
scope: str,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
raise RiskSanctionsAccessError(
|
||||||
|
"A tenant API principal is required."
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
has_scope(principal, scope)
|
||||||
|
or has_scope(principal, SANCTIONS_ADMIN_SCOPE)
|
||||||
|
):
|
||||||
|
raise RiskSanctionsAccessError(f"Missing scope: {scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_id(principal: ApiPrincipal) -> str:
|
||||||
|
return (
|
||||||
|
principal.account_id
|
||||||
|
or principal.membership_id
|
||||||
|
or principal.identity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_FUZZY_THRESHOLD",
|
||||||
|
"DEFAULT_MAX_SNAPSHOT_AGE_DAYS",
|
||||||
|
"MATCHER_VERSION",
|
||||||
|
"MAX_CANDIDATES",
|
||||||
|
"POLICY_VERSION",
|
||||||
|
"ScreeningPolicy",
|
||||||
|
"ScreeningSubject",
|
||||||
|
"get_screening_run",
|
||||||
|
"list_screening_runs",
|
||||||
|
"run_screening",
|
||||||
|
]
|
||||||
@@ -2,22 +2,53 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
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):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_runtime_contract(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "risk_compliance")
|
self.assertEqual(manifest.id, "risk_compliance")
|
||||||
self.assertEqual(manifest.name, "Risk Compliance")
|
self.assertEqual(manifest.name, "Risk Compliance")
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(manifest.dependencies, ("access",))
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
self.assertIn("connectors", manifest.optional_dependencies)
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"risk_compliance_manager", "risk_compliance_viewer"})
|
self.assertEqual(
|
||||||
self.assertTrue(manifest.documentation)
|
{
|
||||||
self.assertIsNone(manifest.route_factory)
|
permission.scope
|
||||||
self.assertIsNone(manifest.migration_spec)
|
for permission in manifest.permissions
|
||||||
self.assertIsNone(manifest.frontend)
|
},
|
||||||
|
{
|
||||||
|
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__":
|
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()
|
||||||
28
webui/package.json
Normal file
28
webui/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/risk-compliance-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/risk-compliance.css": "./src/styles/risk-compliance.css"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
webui/src/api/riskCompliance.ts
Normal file
253
webui/src/api/riskCompliance.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
apiPath,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
export type ConnectorSnapshot = {
|
||||||
|
ref: string;
|
||||||
|
provider_id: string;
|
||||||
|
publisher: string;
|
||||||
|
jurisdiction: string;
|
||||||
|
source_version: string;
|
||||||
|
publication_at?: string | null;
|
||||||
|
acquired_at: string;
|
||||||
|
byte_count: number;
|
||||||
|
sha256: string;
|
||||||
|
parser_version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSnapshot = {
|
||||||
|
id: string;
|
||||||
|
visibility: string;
|
||||||
|
provider_id: string;
|
||||||
|
publisher: string;
|
||||||
|
jurisdiction: string;
|
||||||
|
list_type: string;
|
||||||
|
source_id: string;
|
||||||
|
source_version: string;
|
||||||
|
publication_at?: string | null;
|
||||||
|
effective_at?: string | null;
|
||||||
|
acquired_at: string;
|
||||||
|
sha256: string;
|
||||||
|
connector_run_id: string;
|
||||||
|
raw_evidence_ref: string;
|
||||||
|
source_parser_version: string;
|
||||||
|
normalization_version: string;
|
||||||
|
signature_evidence: Record<string, unknown>;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
entry_count: number;
|
||||||
|
status: string;
|
||||||
|
imported_by?: string | null;
|
||||||
|
imported_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectSnapshot = {
|
||||||
|
id: string;
|
||||||
|
subject_ref?: string | null;
|
||||||
|
subject_type: string;
|
||||||
|
primary_name?: string | null;
|
||||||
|
normalized_name?: string | null;
|
||||||
|
aliases: string[];
|
||||||
|
identifiers: Array<Record<string, string>>;
|
||||||
|
dates: string[];
|
||||||
|
addresses: Array<Record<string, string>>;
|
||||||
|
fingerprint: string;
|
||||||
|
submitted_by?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SanctionsEntry = {
|
||||||
|
id: string;
|
||||||
|
source_entry_id: string;
|
||||||
|
subject_type: string;
|
||||||
|
primary_name: string;
|
||||||
|
original_script_name?: string | null;
|
||||||
|
reference_number?: string | null;
|
||||||
|
listed_on?: string | null;
|
||||||
|
programmes: string[];
|
||||||
|
raw_evidence_locator: string;
|
||||||
|
aliases: Array<{
|
||||||
|
name: string;
|
||||||
|
quality?: string | null;
|
||||||
|
}>;
|
||||||
|
identifiers: Array<{
|
||||||
|
identifier_type: string;
|
||||||
|
value: string;
|
||||||
|
issuing_country?: string | null;
|
||||||
|
}>;
|
||||||
|
dates: Array<{
|
||||||
|
date_type: string;
|
||||||
|
value: string;
|
||||||
|
precision: string;
|
||||||
|
}>;
|
||||||
|
addresses: Array<{
|
||||||
|
street?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
postal_code?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScreeningCandidate = {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
match_kind: string;
|
||||||
|
evidence: Array<Record<string, unknown>>;
|
||||||
|
review_status: string;
|
||||||
|
current_disposition_id?: string | null;
|
||||||
|
entry: SanctionsEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScreeningRun = {
|
||||||
|
id: string;
|
||||||
|
matcher_version: string;
|
||||||
|
normalization_version: string;
|
||||||
|
policy_version: string;
|
||||||
|
policy_snapshot: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
outcome: string;
|
||||||
|
candidate_count: number;
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
subject: SubjectSnapshot;
|
||||||
|
list_snapshot: ListSnapshot;
|
||||||
|
candidates: ScreeningCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReviewQueueItem = {
|
||||||
|
id: string;
|
||||||
|
run_id: string;
|
||||||
|
score: number;
|
||||||
|
match_kind: string;
|
||||||
|
review_status: string;
|
||||||
|
subject_name?: string | null;
|
||||||
|
subject_type: string;
|
||||||
|
entry_name: string;
|
||||||
|
source_entry_id: string;
|
||||||
|
list_source_version: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CandidateDetail = {
|
||||||
|
candidate: ScreeningCandidate;
|
||||||
|
subject: SubjectSnapshot;
|
||||||
|
list_snapshot: ListSnapshot;
|
||||||
|
run: {
|
||||||
|
id: string;
|
||||||
|
matcher_version: string;
|
||||||
|
normalization_version: string;
|
||||||
|
policy_version: string;
|
||||||
|
outcome: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listConnectorSnapshots(
|
||||||
|
settings: ApiSettings
|
||||||
|
) {
|
||||||
|
return apiFetch<{
|
||||||
|
available: boolean;
|
||||||
|
snapshots: ConnectorSnapshot[];
|
||||||
|
}>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/risk-compliance/sanctions/source-snapshots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importListSnapshot(
|
||||||
|
settings: ApiSettings,
|
||||||
|
connectorSnapshotRef: string
|
||||||
|
) {
|
||||||
|
return apiFetch<{
|
||||||
|
snapshot: ListSnapshot;
|
||||||
|
created: boolean;
|
||||||
|
}>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/risk-compliance/sanctions/list-snapshots/import",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
connector_snapshot_ref: connectorSnapshotRef
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listListSnapshots(settings: ApiSettings) {
|
||||||
|
return apiFetch<{ snapshots: ListSnapshot[] }>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/risk-compliance/sanctions/list-snapshots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScreening(
|
||||||
|
settings: ApiSettings,
|
||||||
|
input: {
|
||||||
|
list_snapshot_id: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
subject: {
|
||||||
|
subject_type: "person" | "entity";
|
||||||
|
primary_name: string;
|
||||||
|
identifiers?: Array<{type: string;value: string;}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return apiFetch<{run: ScreeningRun;created: boolean;}>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/risk-compliance/sanctions/screenings",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReviewQueue(
|
||||||
|
settings: ApiSettings,
|
||||||
|
reviewStatus = "pending"
|
||||||
|
) {
|
||||||
|
return apiFetch<{ candidates: ReviewQueueItem[] }>(
|
||||||
|
settings,
|
||||||
|
apiPath(
|
||||||
|
"/api/v1/risk-compliance/sanctions/review-queue",
|
||||||
|
{ review_status: reviewStatus, limit: 500 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCandidate(
|
||||||
|
settings: ApiSettings,
|
||||||
|
candidateId: string
|
||||||
|
) {
|
||||||
|
return apiFetch<CandidateDetail>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/risk-compliance/sanctions/review-queue/${encodeURIComponent(candidateId)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDisposition(
|
||||||
|
settings: ApiSettings,
|
||||||
|
candidateId: string,
|
||||||
|
input: {
|
||||||
|
decision: string;
|
||||||
|
reason: string;
|
||||||
|
exception_scope: "candidate" | "subject_entry";
|
||||||
|
expires_at?: string | null;
|
||||||
|
override_reason?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return apiFetch<{
|
||||||
|
candidate: ScreeningCandidate;
|
||||||
|
disposition: {id: string;decision: string;};
|
||||||
|
}>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/risk-compliance/sanctions/review-queue/${encodeURIComponent(candidateId)}/dispositions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
810
webui/src/features/riskCompliance/RiskCompliancePage.tsx
Normal file
810
webui/src/features/riskCompliance/RiskCompliancePage.tsx
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Database,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Scale,
|
||||||
|
Upload
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type FormEvent
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
IconButton,
|
||||||
|
LoadingIndicator,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
hasScope,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createDisposition,
|
||||||
|
getCandidate,
|
||||||
|
importListSnapshot,
|
||||||
|
listConnectorSnapshots,
|
||||||
|
listListSnapshots,
|
||||||
|
listReviewQueue,
|
||||||
|
runScreening,
|
||||||
|
type CandidateDetail,
|
||||||
|
type ConnectorSnapshot,
|
||||||
|
type ListSnapshot,
|
||||||
|
type ReviewQueueItem,
|
||||||
|
type ScreeningRun
|
||||||
|
} from "../../api/riskCompliance";
|
||||||
|
|
||||||
|
|
||||||
|
type ViewMode = "sources" | "screen" | "review";
|
||||||
|
|
||||||
|
export default function RiskCompliancePage({
|
||||||
|
settings,
|
||||||
|
auth
|
||||||
|
}: PlatformRouteContext) {
|
||||||
|
const [view, setView] = useState<ViewMode>("review");
|
||||||
|
const [sourceSnapshots, setSourceSnapshots] = useState<
|
||||||
|
ConnectorSnapshot[]
|
||||||
|
>([]);
|
||||||
|
const [sourcesAvailable, setSourcesAvailable] = useState(false);
|
||||||
|
const [listSnapshots, setListSnapshots] = useState<ListSnapshot[]>([]);
|
||||||
|
const [queue, setQueue] = useState<ReviewQueueItem[]>([]);
|
||||||
|
const [selectedCandidateId, setSelectedCandidateId] = useState("");
|
||||||
|
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
|
||||||
|
const [run, setRun] = useState<ScreeningRun | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const canAdmin = hasScope(
|
||||||
|
auth,
|
||||||
|
"risk_compliance:sanctions:admin"
|
||||||
|
);
|
||||||
|
const canScreen = hasScope(
|
||||||
|
auth,
|
||||||
|
"risk_compliance:sanctions:screen"
|
||||||
|
);
|
||||||
|
const canReview = hasScope(
|
||||||
|
auth,
|
||||||
|
"risk_compliance:sanctions:review"
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [sources, lists, reviewQueue] = await Promise.all([
|
||||||
|
listConnectorSnapshots(settings),
|
||||||
|
listListSnapshots(settings),
|
||||||
|
canReview
|
||||||
|
? listReviewQueue(settings)
|
||||||
|
: Promise.resolve({ candidates: [] })
|
||||||
|
]);
|
||||||
|
setSourcesAvailable(sources.available);
|
||||||
|
setSourceSnapshots(sources.snapshots);
|
||||||
|
setListSnapshots(lists.snapshots);
|
||||||
|
setQueue(reviewQueue.candidates);
|
||||||
|
setSelectedCandidateId((current) =>
|
||||||
|
current &&
|
||||||
|
reviewQueue.candidates.some((item) => item.id === current)
|
||||||
|
? current
|
||||||
|
: reviewQueue.candidates[0]?.id ?? ""
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(errorMessage(reason));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canReview, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCandidateId) {
|
||||||
|
setCandidate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void getCandidate(settings, selectedCandidateId)
|
||||||
|
.then((item) => {
|
||||||
|
if (!cancelled) setCandidate(item);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!cancelled) setError(errorMessage(reason));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedCandidateId, settings]);
|
||||||
|
|
||||||
|
async function importSnapshot(item: ConnectorSnapshot) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await importListSnapshot(settings, item.ref);
|
||||||
|
setNotice(
|
||||||
|
result.created
|
||||||
|
? `Imported ${result.snapshot.entry_count} list entries.`
|
||||||
|
: "This immutable snapshot was already imported."
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(errorMessage(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="risk-page">
|
||||||
|
<div className="risk-toolbar">
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={setView}
|
||||||
|
ariaLabel="Risk Compliance view"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
id: "sources",
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<Database size={15} />
|
||||||
|
Sources
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "screen",
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<Play size={15} />
|
||||||
|
Screen
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
disabled: !canScreen
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<Scale size={15} />
|
||||||
|
Review
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<span className="risk-count">{queue.length}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
disabled: !canReview
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<span className="risk-toolbar-spacer" />
|
||||||
|
{loading && <LoadingIndicator size="sm" label="Loading" />}
|
||||||
|
<IconButton
|
||||||
|
label="Refresh"
|
||||||
|
icon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
disabled={loading || busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(error || notice) && (
|
||||||
|
<div className="risk-alerts">
|
||||||
|
{error && (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="danger"
|
||||||
|
resetKey={error}
|
||||||
|
onDismiss={() => setError("")}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
{notice && !error && (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="success"
|
||||||
|
resetKey={notice}
|
||||||
|
onDismiss={() => setNotice("")}
|
||||||
|
>
|
||||||
|
{notice}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="risk-workspace">
|
||||||
|
{view === "sources" && (
|
||||||
|
<SourcesPane
|
||||||
|
available={sourcesAvailable}
|
||||||
|
sources={sourceSnapshots}
|
||||||
|
imported={listSnapshots}
|
||||||
|
canImport={canAdmin}
|
||||||
|
busy={busy}
|
||||||
|
onImport={importSnapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === "screen" && (
|
||||||
|
<ScreenPane
|
||||||
|
settings={settings}
|
||||||
|
snapshots={listSnapshots}
|
||||||
|
run={run}
|
||||||
|
onRun={setRun}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === "review" && (
|
||||||
|
<ReviewPane
|
||||||
|
queue={queue}
|
||||||
|
selectedId={selectedCandidateId}
|
||||||
|
detail={candidate}
|
||||||
|
busy={busy}
|
||||||
|
onSelect={setSelectedCandidateId}
|
||||||
|
onBusy={setBusy}
|
||||||
|
onError={setError}
|
||||||
|
onNotice={setNotice}
|
||||||
|
onRefresh={refresh}
|
||||||
|
settings={settings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourcesPane({
|
||||||
|
available,
|
||||||
|
sources,
|
||||||
|
imported,
|
||||||
|
canImport,
|
||||||
|
busy,
|
||||||
|
onImport
|
||||||
|
}: {
|
||||||
|
available: boolean;
|
||||||
|
sources: ConnectorSnapshot[];
|
||||||
|
imported: ListSnapshot[];
|
||||||
|
canImport: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onImport: (item: ConnectorSnapshot) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const importedRefs = useMemo(
|
||||||
|
() => new Set(imported.map((item) => item.sha256)),
|
||||||
|
[imported]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="risk-source-layout">
|
||||||
|
<div className="risk-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong>Connector evidence</strong>
|
||||||
|
<span>Immutable acquired source snapshots</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{!available && (
|
||||||
|
<div className="risk-empty">
|
||||||
|
The Connectors sanctions source capability is not enabled.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="risk-list">
|
||||||
|
{sources.map((item) => {
|
||||||
|
const isImported = importedRefs.has(item.sha256);
|
||||||
|
return (
|
||||||
|
<div className="risk-list-row" key={item.ref}>
|
||||||
|
<div className="risk-list-main">
|
||||||
|
<strong>{item.publisher}</strong>
|
||||||
|
<span>
|
||||||
|
{item.source_version} · {formatDate(item.acquired_at)}
|
||||||
|
</span>
|
||||||
|
<code>{item.sha256.slice(0, 16)}…</code>
|
||||||
|
</div>
|
||||||
|
{isImported ? (
|
||||||
|
<StatusBadge status="active" label="Imported" />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
label="Import snapshot"
|
||||||
|
icon={<Upload size={16} />}
|
||||||
|
disabled={!canImport || busy}
|
||||||
|
onClick={() => void onImport(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="risk-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong>Screening catalogues</strong>
|
||||||
|
<span>Normalized, immutable list versions</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="risk-list">
|
||||||
|
{imported.map((item) => (
|
||||||
|
<div className="risk-list-row" key={item.id}>
|
||||||
|
<div className="risk-list-main">
|
||||||
|
<strong>{item.publisher}</strong>
|
||||||
|
<span>
|
||||||
|
{item.entry_count.toLocaleString()} entries ·{" "}
|
||||||
|
{item.normalization_version}
|
||||||
|
</span>
|
||||||
|
<code>{item.source_version}</code>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!imported.length && (
|
||||||
|
<div className="risk-empty">
|
||||||
|
No source snapshot has been imported.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScreenPane({
|
||||||
|
settings,
|
||||||
|
snapshots,
|
||||||
|
run,
|
||||||
|
onRun,
|
||||||
|
onError
|
||||||
|
}: {
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
snapshots: ListSnapshot[];
|
||||||
|
run: ScreeningRun | null;
|
||||||
|
onRun: (run: ScreeningRun | null) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [listId, setListId] = useState(snapshots[0]?.id ?? "");
|
||||||
|
const [subjectType, setSubjectType] = useState<"person" | "entity">(
|
||||||
|
"person"
|
||||||
|
);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [identifier, setIdentifier] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshots.some((item) => item.id === listId)) {
|
||||||
|
setListId(snapshots[0]?.id ?? "");
|
||||||
|
}
|
||||||
|
}, [listId, snapshots]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
onError("");
|
||||||
|
try {
|
||||||
|
const response = await runScreening(settings, {
|
||||||
|
list_snapshot_id: listId,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
subject: {
|
||||||
|
subject_type: subjectType,
|
||||||
|
primary_name: name,
|
||||||
|
identifiers: identifier.trim()
|
||||||
|
? [{ type: "document", value: identifier.trim() }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onRun(response.run);
|
||||||
|
} catch (reason) {
|
||||||
|
onError(errorMessage(reason));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="risk-screen-layout">
|
||||||
|
<form className="risk-panel risk-screen-form" onSubmit={submit}>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong>New screening</strong>
|
||||||
|
<span>Use only the data needed for comparison</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="risk-form-body">
|
||||||
|
<FormField label="List snapshot">
|
||||||
|
<select
|
||||||
|
value={listId}
|
||||||
|
onChange={(event) => setListId(event.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{snapshots.map((item) => (
|
||||||
|
<option value={item.id} key={item.id}>
|
||||||
|
{item.publisher} · {item.source_version}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Subject type">
|
||||||
|
<SegmentedControl
|
||||||
|
value={subjectType}
|
||||||
|
onChange={setSubjectType}
|
||||||
|
width="fill"
|
||||||
|
size="equal"
|
||||||
|
options={[
|
||||||
|
{ id: "person", label: "Person" },
|
||||||
|
{ id: "entity", label: "Entity" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Name">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Identifier (optional)">
|
||||||
|
<input
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
!listId ||
|
||||||
|
(!name.trim() && !identifier.trim())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
Run screening
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="risk-panel risk-run-result">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong>Result</strong>
|
||||||
|
<span>Version-pinned candidate evidence</span>
|
||||||
|
</div>
|
||||||
|
{run && <StatusBadge status={run.outcome} />}
|
||||||
|
</header>
|
||||||
|
{!run && (
|
||||||
|
<div className="risk-empty">
|
||||||
|
Run a screening to inspect the result.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{run && (
|
||||||
|
<div className="risk-result-body">
|
||||||
|
<div className="risk-metrics">
|
||||||
|
<span>
|
||||||
|
<strong>{run.candidate_count}</strong>
|
||||||
|
candidates
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{run.matcher_version}</strong>
|
||||||
|
matcher
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{run.list_snapshot.source_version}</strong>
|
||||||
|
list
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{run.candidates.map((item) => (
|
||||||
|
<div className="risk-candidate-summary" key={item.id}>
|
||||||
|
<span className="risk-score">{item.score}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{item.entry.primary_name}</strong>
|
||||||
|
<span>
|
||||||
|
{item.match_kind} · {item.entry.source_entry_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.review_status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{run.outcome === "clear" && (
|
||||||
|
<div className="risk-clear">
|
||||||
|
<CheckCircle2 size={18} />
|
||||||
|
No candidate met the configured threshold.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewPane({
|
||||||
|
queue,
|
||||||
|
selectedId,
|
||||||
|
detail,
|
||||||
|
busy,
|
||||||
|
onSelect,
|
||||||
|
onBusy,
|
||||||
|
onError,
|
||||||
|
onNotice,
|
||||||
|
onRefresh,
|
||||||
|
settings
|
||||||
|
}: {
|
||||||
|
queue: ReviewQueueItem[];
|
||||||
|
selectedId: string;
|
||||||
|
detail: CandidateDetail | null;
|
||||||
|
busy: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onBusy: (value: boolean) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onNotice: (message: string) => void;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
}) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [decision, setDecision] = useState("false_positive");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [reusable, setReusable] = useState(false);
|
||||||
|
const [expiresAt, setExpiresAt] = useState("");
|
||||||
|
|
||||||
|
async function submitDisposition(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!detail) return;
|
||||||
|
onBusy(true);
|
||||||
|
onError("");
|
||||||
|
try {
|
||||||
|
await createDisposition(settings, detail.candidate.id, {
|
||||||
|
decision,
|
||||||
|
reason,
|
||||||
|
exception_scope: reusable ? "subject_entry" : "candidate",
|
||||||
|
expires_at:
|
||||||
|
reusable && expiresAt
|
||||||
|
? new Date(`${expiresAt}T23:59:59`).toISOString()
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
setDialogOpen(false);
|
||||||
|
setReason("");
|
||||||
|
setReusable(false);
|
||||||
|
onNotice("The disposition was recorded as append-only evidence.");
|
||||||
|
await onRefresh();
|
||||||
|
} catch (reasonValue) {
|
||||||
|
onError(errorMessage(reasonValue));
|
||||||
|
} finally {
|
||||||
|
onBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="risk-review-layout">
|
||||||
|
<aside className="risk-panel risk-review-queue">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong>Review queue</strong>
|
||||||
|
<span>{queue.length} candidates need review</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="risk-list">
|
||||||
|
{queue.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
item.id === selectedId
|
||||||
|
? "risk-queue-row selected"
|
||||||
|
: "risk-queue-row"
|
||||||
|
}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
<span className="risk-score">{item.score}</span>
|
||||||
|
<span className="risk-list-main">
|
||||||
|
<strong>{item.subject_name || "Identifier-only subject"}</strong>
|
||||||
|
<span>{item.entry_name}</span>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={item.review_status} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!queue.length && (
|
||||||
|
<div className="risk-empty">
|
||||||
|
No candidates currently need review.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="risk-panel risk-evidence">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong>Candidate evidence</strong>
|
||||||
|
<span>Subject and immutable list entry comparison</span>
|
||||||
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Record disposition
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{!detail && (
|
||||||
|
<div className="risk-empty">
|
||||||
|
Select a candidate from the queue.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail && (
|
||||||
|
<div className="risk-evidence-body">
|
||||||
|
<div className="risk-comparison">
|
||||||
|
<EvidenceColumn
|
||||||
|
title="Screening subject"
|
||||||
|
name={detail.subject.primary_name || "Identifier-only"}
|
||||||
|
type={detail.subject.subject_type}
|
||||||
|
aliases={detail.subject.aliases}
|
||||||
|
identifiers={detail.subject.identifiers.map(
|
||||||
|
(item) => item.value || ""
|
||||||
|
)}
|
||||||
|
dates={detail.subject.dates}
|
||||||
|
/>
|
||||||
|
<EvidenceColumn
|
||||||
|
title="Sanctions list entry"
|
||||||
|
name={detail.candidate.entry.primary_name}
|
||||||
|
type={detail.candidate.entry.subject_type}
|
||||||
|
aliases={detail.candidate.entry.aliases.map(
|
||||||
|
(item) => item.name
|
||||||
|
)}
|
||||||
|
identifiers={detail.candidate.entry.identifiers.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.identifier_type}: ${item.value}`
|
||||||
|
)}
|
||||||
|
dates={detail.candidate.entry.dates.map(
|
||||||
|
(item) => item.value
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="risk-match-evidence">
|
||||||
|
<strong>
|
||||||
|
{detail.candidate.score}% ·{" "}
|
||||||
|
{detail.candidate.match_kind}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
List {detail.list_snapshot.source_version} ·{" "}
|
||||||
|
{detail.run.matcher_version} ·{" "}
|
||||||
|
{detail.run.normalization_version}
|
||||||
|
</span>
|
||||||
|
<code>
|
||||||
|
{detail.candidate.entry.raw_evidence_locator}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title="Record screening disposition"
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
form="risk-disposition-form"
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={
|
||||||
|
busy ||
|
||||||
|
reason.trim().length < 3 ||
|
||||||
|
(reusable && !expiresAt)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Record
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
id="risk-disposition-form"
|
||||||
|
className="risk-disposition-form"
|
||||||
|
onSubmit={submitDisposition}
|
||||||
|
>
|
||||||
|
<FormField label="Decision">
|
||||||
|
<select
|
||||||
|
value={decision}
|
||||||
|
onChange={(event) => setDecision(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="false_positive">False positive</option>
|
||||||
|
<option value="true_match">Confirmed match</option>
|
||||||
|
<option value="needs_information">
|
||||||
|
More information needed
|
||||||
|
</option>
|
||||||
|
<option value="escalated">Escalate</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Reason">
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
rows={5}
|
||||||
|
maxLength={10000}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{decision === "false_positive" && (
|
||||||
|
<>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={reusable}
|
||||||
|
onChange={(event) => setReusable(event.target.checked)}
|
||||||
|
label="Apply as a time-bounded exception to this subject and list entry"
|
||||||
|
/>
|
||||||
|
{reusable && (
|
||||||
|
<FormField label="Exception expires">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(event) => setExpiresAt(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceColumn({
|
||||||
|
title,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
aliases,
|
||||||
|
identifiers,
|
||||||
|
dates
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
aliases: string[];
|
||||||
|
identifiers: string[];
|
||||||
|
dates: string[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="risk-evidence-column">
|
||||||
|
<span className="risk-eyebrow">{title}</span>
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<span>{type}</span>
|
||||||
|
<EvidenceValues label="Aliases" values={aliases} />
|
||||||
|
<EvidenceValues label="Identifiers" values={identifiers} />
|
||||||
|
<EvidenceValues label="Dates" values={dates} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceValues({
|
||||||
|
label,
|
||||||
|
values
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
values: string[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="risk-evidence-values">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{values.filter(Boolean).join(", ") || "None"}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(reason: unknown) {
|
||||||
|
return reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "The Risk Compliance operation failed.";
|
||||||
|
}
|
||||||
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, riskComplianceModule } from "./module";
|
||||||
|
export * from "./api/riskCompliance";
|
||||||
71
webui/src/module.ts
Normal file
71
webui/src/module.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/risk-compliance.css";
|
||||||
|
|
||||||
|
|
||||||
|
const RiskCompliancePage = lazy(
|
||||||
|
() => import("./features/riskCompliance/RiskCompliancePage")
|
||||||
|
);
|
||||||
|
|
||||||
|
export const riskComplianceModule: PlatformWebModule = {
|
||||||
|
id: "risk_compliance",
|
||||||
|
label: "Risk Compliance",
|
||||||
|
version: "0.1.8",
|
||||||
|
dependencies: ["access"],
|
||||||
|
optionalDependencies: [
|
||||||
|
"audit",
|
||||||
|
"policy",
|
||||||
|
"records",
|
||||||
|
"inspections",
|
||||||
|
"files",
|
||||||
|
"tasks",
|
||||||
|
"notifications",
|
||||||
|
"connectors",
|
||||||
|
"views",
|
||||||
|
"workflow"
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/risk-compliance",
|
||||||
|
label: "Risk Compliance",
|
||||||
|
iconName: "shield-check",
|
||||||
|
anyOf: ["risk_compliance:sanctions:read"],
|
||||||
|
order: 115,
|
||||||
|
surfaceId: "risk_compliance.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/risk-compliance",
|
||||||
|
anyOf: ["risk_compliance:sanctions:read"],
|
||||||
|
order: 115,
|
||||||
|
surfaceId: "risk_compliance.workspace",
|
||||||
|
render: (context) => createElement(RiskCompliancePage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "risk_compliance.sanctions.sources",
|
||||||
|
moduleId: "risk_compliance",
|
||||||
|
kind: "section",
|
||||||
|
label: "Sanctions source snapshots",
|
||||||
|
order: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "risk_compliance.sanctions.screening",
|
||||||
|
moduleId: "risk_compliance",
|
||||||
|
kind: "section",
|
||||||
|
label: "Sanctions screening",
|
||||||
|
order: 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "risk_compliance.sanctions.review",
|
||||||
|
moduleId: "risk_compliance",
|
||||||
|
kind: "section",
|
||||||
|
label: "Sanctions review queue",
|
||||||
|
order: 40
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default riskComplianceModule;
|
||||||
353
webui/src/styles/risk-compliance.css
Normal file
353
webui/src/styles/risk-compliance.css
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
.risk-page {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 50px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-toolbar .segmented-control-option {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-count {
|
||||||
|
min-width: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-alerts {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-workspace {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-source-layout,
|
||||||
|
.risk-screen-layout,
|
||||||
|
.risk-review-layout {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
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-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-list,
|
||||||
|
.risk-result-body,
|
||||||
|
.risk-evidence-body {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list-row,
|
||||||
|
.risk-queue-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 {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-queue-row:hover,
|
||||||
|
.risk-queue-row.selected {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel {
|
||||||
|
min-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-comparison {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-column {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user