from __future__ import annotations import json from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_core.core.dsar import ( DsarErasureActionRef, DsarExecutionResultRef, DsarRecordRef, DsarSubjectRef, dsar_capability_name, ) from govoplan_risk_compliance.backend.db.models import ( RiskAssuranceEdge, RiskAssuranceNode, RiskSanctionsListSnapshot, RiskScreeningCandidate, RiskScreeningDisposition, RiskScreeningException, RiskScreeningRun, RiskScreeningSubjectSnapshot, ) RISK_COMPLIANCE_DSAR_CAPABILITY = dsar_capability_name("risk_compliance") _MAX_RECORDS = 5_000 _MAX_SUBJECT_ITEMS = 100 _MAX_SUBJECT_BYTES = 256 * 1024 _CONFLICT = object() @dataclass(frozen=True, slots=True) class _SubjectSelectors: account_id: str | None membership_id: str | None subject_ref: str | None screening_id: str | None assurance_node_id: str | None assurance_edge_id: str | None @property def actor_ids(self) -> tuple[str, ...]: return tuple( dict.fromkeys( value for value in (self.account_id, self.membership_id) if value ) ) @property def narrowed(self) -> bool: return bool( self.screening_id or self.assurance_node_id or self.assurance_edge_id ) class RiskComplianceDsarProvider: provider_id = "risk_compliance" module_id = "risk_compliance" def search_subject( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, ) -> Sequence[DsarRecordRef]: db = _session(session) selectors = _subject_selectors(subject) if selectors is None: return () records: list[DsarRecordRef] = [] if selectors.subject_ref and (not selectors.narrowed or selectors.screening_id): query = ( db.query(RiskScreeningRun, RiskScreeningSubjectSnapshot) .join( RiskScreeningSubjectSnapshot, RiskScreeningSubjectSnapshot.id == RiskScreeningRun.subject_snapshot_id, ) .filter( RiskScreeningRun.tenant_id == tenant_id, RiskScreeningSubjectSnapshot.tenant_id == tenant_id, RiskScreeningSubjectSnapshot.subject_ref == selectors.subject_ref, ) ) if selectors.screening_id: query = query.filter(RiskScreeningRun.id == selectors.screening_id) records.extend( _subject_screening_record(run, snapshot) for run, snapshot in _limited( query, RiskScreeningRun.started_at, RiskScreeningRun.id, label="subject screening", ) ) actor_ids = selectors.actor_ids if actor_ids and (not selectors.narrowed or selectors.screening_id): run_query = ( db.query(RiskScreeningRun, RiskScreeningSubjectSnapshot) .join( RiskScreeningSubjectSnapshot, RiskScreeningSubjectSnapshot.id == RiskScreeningRun.subject_snapshot_id, ) .filter( RiskScreeningRun.tenant_id == tenant_id, RiskScreeningSubjectSnapshot.tenant_id == tenant_id, or_( RiskScreeningRun.created_by.in_(actor_ids), RiskScreeningSubjectSnapshot.submitted_by.in_(actor_ids), ), ) ) if selectors.screening_id: run_query = run_query.filter( RiskScreeningRun.id == selectors.screening_id ) records.extend( _screening_actor_record(run, snapshot, actor_ids) for run, snapshot in _limited( run_query, RiskScreeningRun.started_at, RiskScreeningRun.id, label="screening actor attribution", ) ) disposition_query = db.query(RiskScreeningDisposition).filter( RiskScreeningDisposition.tenant_id == tenant_id, or_( RiskScreeningDisposition.actor_account_id.in_(actor_ids), RiskScreeningDisposition.actor_membership_id.in_(actor_ids), ), ) if selectors.screening_id: disposition_query = ( db.query(RiskScreeningDisposition) .join( RiskScreeningCandidate, RiskScreeningCandidate.id == RiskScreeningDisposition.candidate_id, ) .filter( RiskScreeningDisposition.tenant_id == tenant_id, RiskScreeningCandidate.run_id == selectors.screening_id, or_( RiskScreeningDisposition.actor_account_id.in_(actor_ids), RiskScreeningDisposition.actor_membership_id.in_(actor_ids), ), ) ) records.extend( _disposition_actor_record(row) for row in _limited( disposition_query, RiskScreeningDisposition.created_at, RiskScreeningDisposition.id, label="disposition attribution", ) ) if actor_ids and not selectors.narrowed: records.extend(self._unnarrowed_actor_records(db, tenant_id, actor_ids)) if actor_ids and selectors.assurance_node_id: query = db.query(RiskAssuranceNode).filter( RiskAssuranceNode.tenant_id == tenant_id, RiskAssuranceNode.id == selectors.assurance_node_id, RiskAssuranceNode.created_by.in_(actor_ids), ) records.extend( _assurance_node_actor_record(row) for row in _limited( query, RiskAssuranceNode.recorded_at, RiskAssuranceNode.id, label="assurance-node attribution", ) ) if actor_ids and selectors.assurance_edge_id: query = db.query(RiskAssuranceEdge).filter( RiskAssuranceEdge.tenant_id == tenant_id, RiskAssuranceEdge.id == selectors.assurance_edge_id, RiskAssuranceEdge.created_by.in_(actor_ids), ) records.extend( _assurance_edge_actor_record(row) for row in _limited( query, RiskAssuranceEdge.recorded_at, RiskAssuranceEdge.id, label="assurance-edge attribution", ) ) if len(records) > _MAX_RECORDS: raise ValueError( "Risk Compliance DSAR result limit exceeded; narrow selectors." ) return tuple( sorted(records, key=lambda item: (item.resource_type, item.resource_id)) ) @staticmethod def _unnarrowed_actor_records( db: Session, tenant_id: str, actor_ids: tuple[str, ...], ) -> list[DsarRecordRef]: records: list[DsarRecordRef] = [] imports = db.query(RiskSanctionsListSnapshot).filter( RiskSanctionsListSnapshot.tenant_id == tenant_id, RiskSanctionsListSnapshot.imported_by.in_(actor_ids), ) records.extend( _snapshot_import_actor_record(row) for row in _limited( imports, RiskSanctionsListSnapshot.imported_at, RiskSanctionsListSnapshot.id, label="snapshot-import attribution", ) ) exceptions = db.query(RiskScreeningException).filter( RiskScreeningException.tenant_id == tenant_id, RiskScreeningException.created_by.in_(actor_ids), ) records.extend( _exception_actor_record(row) for row in _limited( exceptions, RiskScreeningException.created_at, RiskScreeningException.id, label="exception attribution", ) ) nodes = db.query(RiskAssuranceNode).filter( RiskAssuranceNode.tenant_id == tenant_id, RiskAssuranceNode.created_by.in_(actor_ids), ) records.extend( _assurance_node_actor_record(row) for row in _limited( nodes, RiskAssuranceNode.recorded_at, RiskAssuranceNode.id, label="assurance-node attribution", ) ) edges = db.query(RiskAssuranceEdge).filter( RiskAssuranceEdge.tenant_id == tenant_id, RiskAssuranceEdge.created_by.in_(actor_ids), ) records.extend( _assurance_edge_actor_record(row) for row in _limited( edges, RiskAssuranceEdge.recorded_at, RiskAssuranceEdge.id, label="assurance-edge attribution", ) ) return records def plan_erasure( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, records: Sequence[DsarRecordRef], ) -> Sequence[DsarErasureActionRef]: del tenant_id _session(session) if _subject_selectors(subject) is None: raise ValueError("Risk Compliance DSAR subject selectors conflict.") actions: list[DsarErasureActionRef] = [] for record in records: _validate_record(record) actions.append( DsarErasureActionRef( action_id=( f"risk_compliance:retain:{record.resource_type}:" f"{record.resource_id}" ), provider_id=self.provider_id, module_id=self.module_id, kind="retain", resource_type=record.resource_type, resource_id=record.resource_id, title=f"Retain {record.title}", rationale=( record.retention_reason or "Risk and compliance evidence remains immutable." ), executable=False, ) ) return tuple(actions) def execute_erasure( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, actions: Sequence[DsarErasureActionRef], request_id: str, ) -> Sequence[DsarExecutionResultRef]: del tenant_id _session(session) if _subject_selectors(subject) is None: raise ValueError("Risk Compliance DSAR subject selectors conflict.") results: list[DsarExecutionResultRef] = [] for action in actions: _validate_action(action) if action.executable or action.kind != "retain": raise ValueError("Risk Compliance DSAR publishes retain actions only.") results.append( DsarExecutionResultRef( action_id=action.action_id, status="blocked", summary=( "Risk and compliance evidence remains unchanged under its " "legal, audit, and accountability obligations." ), evidence={"request_id": request_id}, ) ) return tuple(results) def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: references = subject.external_references values = { "account_id": _coalesce( subject.account_id, references.get("risk_compliance.account"), references.get("access.account"), ), "membership_id": _coalesce( subject.membership_id, references.get("risk_compliance.membership"), references.get("tenancy.membership"), ), "subject_ref": _coalesce( references.get("risk_compliance.subject"), references.get("risk_compliance.subject_ref"), ), "screening_id": _coalesce( references.get("risk_compliance.screening"), references.get("risk_compliance.screening_id"), ), "assurance_node_id": _coalesce( references.get("risk_compliance.assurance_node"), references.get("risk_compliance.assurance_node_id"), ), "assurance_edge_id": _coalesce( references.get("risk_compliance.assurance_edge"), references.get("risk_compliance.assurance_edge_id"), ), } if any(value is _CONFLICT for value in values.values()): return None selectors = _SubjectSelectors( account_id=_optional_string(values["account_id"]), membership_id=_optional_string(values["membership_id"]), subject_ref=_optional_string(values["subject_ref"]), screening_id=_optional_string(values["screening_id"]), assurance_node_id=_optional_string(values["assurance_node_id"]), assurance_edge_id=_optional_string(values["assurance_edge_id"]), ) if not selectors.actor_ids and not selectors.subject_ref: return None return selectors def _subject_screening_record( run: RiskScreeningRun, snapshot: RiskScreeningSubjectSnapshot ) -> DsarRecordRef: data = { "screening_id": run.id, "subject_snapshot_id": snapshot.id, "subject_ref": snapshot.subject_ref, "subject_type": snapshot.subject_type, "primary_name": (snapshot.primary_name or "")[:1_000] or None, "aliases": _string_list(snapshot.aliases, 1_000), "identifiers": _mapping_list( snapshot.identifiers, allowed=("type", "value"), limits={"type": 100, "value": 1_000}, ), "dates": _string_list(snapshot.dates, 100), "addresses": _mapping_list( snapshot.addresses, allowed=("street", "city", "region", "postal_code", "country"), limits={ "street": 1_000, "city": 500, "region": 500, "postal_code": 100, "country": 255, }, ), "status": run.status, "outcome": run.outcome, "candidate_count": run.candidate_count, "started_at": _iso(run.started_at), "completed_at": _iso(run.completed_at), } _bounded_json(data) return _record( resource_type="screening_subject_submission", resource_id=run.id, category="sanctions_screening_subject_data", title="Sanctions screening subject submission", data=data, observed_at=run.completed_at or run.started_at, retention_reason=( "Version-pinned screening inputs and outcomes are retained as legal and " "compliance evidence." ), ) def _screening_actor_record( run: RiskScreeningRun, snapshot: RiskScreeningSubjectSnapshot, actor_ids: tuple[str, ...], ) -> DsarRecordRef: activities = [] if run.created_by in actor_ids: activities.append("created_screening") if snapshot.submitted_by in actor_ids: activities.append("submitted_screening_subject") return _record( resource_type="screening_actor_attribution", resource_id=run.id, category="risk_compliance_actor_attribution", title="Screening actor attribution", data={ "screening_id": run.id, "status": run.status, "outcome": run.outcome, "candidate_count": run.candidate_count, "activities": activities, "started_at": _iso(run.started_at), "completed_at": _iso(run.completed_at), }, observed_at=run.completed_at or run.started_at, ) def _snapshot_import_actor_record(row: RiskSanctionsListSnapshot) -> DsarRecordRef: return _record( resource_type="snapshot_import_actor_attribution", resource_id=row.id, category="risk_compliance_actor_attribution", title="Sanctions snapshot import attribution", data={ "snapshot_id": row.id, "provider_id": row.provider_id, "source_id": row.source_id, "source_version": row.source_version, "status": row.status, "entry_count": row.entry_count, "activity": "imported_sanctions_snapshot", "imported_at": _iso(row.imported_at), }, observed_at=row.imported_at, ) def _disposition_actor_record(row: RiskScreeningDisposition) -> DsarRecordRef: return _record( resource_type="disposition_actor_attribution", resource_id=row.id, category="risk_compliance_actor_attribution", title="Screening disposition actor attribution", data={ "disposition_id": row.id, "candidate_id": row.candidate_id, "decision": row.decision, "scope": row.scope, "separation_status": row.separation_status, "expires_at": _iso(row.expires_at), "review_at": _iso(row.review_at), "activity": "recorded_screening_disposition", "created_at": _iso(row.created_at), }, observed_at=row.created_at, ) def _exception_actor_record(row: RiskScreeningException) -> DsarRecordRef: return _record( resource_type="exception_actor_attribution", resource_id=row.id, category="risk_compliance_actor_attribution", title="Screening exception actor attribution", data={ "exception_id": row.id, "scope": row.scope, "status": row.status, "starts_at": _iso(row.starts_at), "expires_at": _iso(row.expires_at), "review_at": _iso(row.review_at), "activity": "created_screening_exception", }, observed_at=row.created_at, ) def _assurance_node_actor_record(row: RiskAssuranceNode) -> DsarRecordRef: return _record( resource_type="assurance_node_actor_attribution", resource_id=row.id, category="risk_compliance_actor_attribution", title="Assurance-object actor attribution", data={ "assurance_node_id": row.id, "stable_id": row.stable_id, "kind": row.kind, "revision": row.revision, "state": row.state, "valid_from": _iso(row.valid_from), "valid_to": _iso(row.valid_to), "recorded_at": _iso(row.recorded_at), "superseded_at": _iso(row.superseded_at), "activity": "created_assurance_object_revision", }, observed_at=row.recorded_at, ) def _assurance_edge_actor_record(row: RiskAssuranceEdge) -> DsarRecordRef: return _record( resource_type="assurance_edge_actor_attribution", resource_id=row.id, category="risk_compliance_actor_attribution", title="Assurance-relation actor attribution", data={ "assurance_edge_id": row.id, "stable_id": row.stable_id, "revision": row.revision, "relation": row.relation, "state": row.state, "valid_from": _iso(row.valid_from), "valid_to": _iso(row.valid_to), "recorded_at": _iso(row.recorded_at), "superseded_at": _iso(row.superseded_at), "activity": "created_assurance_relation_revision", }, observed_at=row.recorded_at, ) def _record( *, resource_type: str, resource_id: str, category: str, title: str, data: Mapping[str, object], observed_at: datetime | None, retention_reason: str | None = None, ) -> DsarRecordRef: return DsarRecordRef( provider_id="risk_compliance", module_id="risk_compliance", resource_type=resource_type, resource_id=resource_id, category=category, title=title, data=data, observed_at=_aware(observed_at), immutable_evidence=True, retention_reason=( retention_reason or "Risk and compliance attribution is retained for legal, audit, and accountability evidence." ), ) def _string_list(value: object, item_limit: int) -> list[str]: if not isinstance(value, list) or len(value) > _MAX_SUBJECT_ITEMS: raise ValueError("Risk Compliance DSAR subject list exceeds its bound.") return [str(item)[:item_limit] for item in value] def _mapping_list( value: object, *, allowed: tuple[str, ...], limits: Mapping[str, int], ) -> list[dict[str, str]]: if not isinstance(value, list) or len(value) > _MAX_SUBJECT_ITEMS: raise ValueError("Risk Compliance DSAR subject mapping list exceeds its bound.") result: list[dict[str, str]] = [] for item in value: if not isinstance(item, Mapping): raise ValueError("Risk Compliance DSAR subject mapping is invalid.") result.append( { key: str(item[key])[: limits[key]] for key in allowed if item.get(key) is not None } ) return result def _bounded_json(value: object) -> None: try: encoded = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8") except (TypeError, ValueError) as exc: raise ValueError("Risk Compliance DSAR subject data is invalid.") from exc if len(encoded) > _MAX_SUBJECT_BYTES: raise ValueError("Risk Compliance DSAR subject data exceeds its byte bound.") def _limited(query, first, second, *, label: str): rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all() if len(rows) > _MAX_RECORDS: raise ValueError( f"Risk Compliance DSAR {label} limit exceeded; narrow selectors." ) return rows def _coalesce(*values: str | None) -> str | None | object: normalized = {str(value).strip() for value in values if str(value or "").strip()} if len(normalized) > 1: return _CONFLICT return next(iter(normalized), None) def _optional_string(value: object) -> str | None: return value if isinstance(value, str) and value else None def _iso(value: datetime | None) -> str | None: aware = _aware(value) return aware.isoformat() if aware else None def _aware(value: datetime | None) -> datetime | None: if value is None or value.tzinfo is not None: return value return value.replace(tzinfo=timezone.utc) def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Risk Compliance DSAR requires a SQLAlchemy Session.") return value _RESOURCE_TYPES = { "screening_subject_submission", "screening_actor_attribution", "snapshot_import_actor_attribution", "disposition_actor_attribution", "exception_actor_attribution", "assurance_node_actor_attribution", "assurance_edge_actor_attribution", } def _validate_record(record: DsarRecordRef) -> None: if record.provider_id != "risk_compliance" or record.module_id != "risk_compliance": raise ValueError("Risk Compliance DSAR cannot plan a foreign provider record.") if record.resource_type not in _RESOURCE_TYPES or not record.resource_id: raise ValueError("Risk Compliance DSAR record identity is invalid.") def _validate_action(action: DsarErasureActionRef) -> None: if action.provider_id != "risk_compliance" or action.module_id != "risk_compliance": raise ValueError( "Risk Compliance DSAR cannot execute a foreign provider action." ) if not action.action_id.startswith("risk_compliance:retain:"): raise ValueError("Risk Compliance DSAR action identity is invalid.") __all__ = ["RISK_COMPLIANCE_DSAR_CAPABILITY", "RiskComplianceDsarProvider"]