feat: implement sanctions screening vertical
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user