14 Commits
Author SHA1 Message Date
zemion 8a50529f71 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:46 +02:00
zemion a633100a97 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:10 +02:00
zemion 08ec16f459 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:13 +02:00
zemion fe9c44ff44 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 12s
2026-08-04 15:18:13 +02:00
zemion 0ab9f76bc9 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 075aa4102c Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 5010f0833d Add protected package release workflow 2026-08-04 04:14:07 +02:00
zemion 24d80a6d7d Migrate Risk Compliance interface patterns 2026-08-03 15:47:05 +02:00
zemion a23b53dc9e feat: implement assurance graph and screening evidence 2026-08-01 17:48:39 +02:00
zemion bbf7288e14 Align WebUI runtime peer dependencies 2026-07-31 02:48:56 +02:00
zemion da1baede36 feat: reconcile sanctions screening freshness 2026-07-30 01:55:03 +02:00
zemion 11404ac42f feat: implement sanctions screening vertical 2026-07-29 18:46:53 +02:00
zemion 05c3a2257a fix(manifest): normalize runtime module id 2026-07-20 20:01:50 +02:00
zemion 587991dcfb Release v0.1.8 2026-07-11 16:49:04 +02:00
37 changed files with 12275 additions and 60 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+7 -1
View File
@@ -1,5 +1,11 @@
# GovOPlaN Risk Compliance Codex Guide # GovOPlaN Risk Compliance Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Risk Compliance internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope ## Scope
This repository owns the GovOPlaN Risk Compliance platform module seed. This repository owns the GovOPlaN Risk Compliance platform module seed.
@@ -20,5 +26,5 @@ Use Gitea issues as the canonical backlog and state log. The shared workflow is
Focused verification: Focused verification:
```bash ```bash
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
``` ```
+25 -4
View File
@@ -1,8 +1,23 @@
# GovOPlaN Risk Compliance # GovOPlaN Risk Compliance
`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-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
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. `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.
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,
freshness reconciliation, explicit consumer gates, and audit events. It also
provides a revisioned horizontal assurance graph from obligation through
effectiveness review, with bounded traversal, opaque governed-object links,
search, and optimistic concurrency. Queue, search, audit, and aggregate
summaries deliberately retain only the minimum subject data needed for their
purpose.
## Initial Ownership ## Initial Ownership
@@ -12,6 +27,11 @@ 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
- stable screening evidence references and freshness gates
- effective-dated obligations, risks, controls, evidence, findings, corrective
measures, and effectiveness reviews
## Boundaries ## Boundaries
@@ -34,6 +54,7 @@ Expected optional integrations:
- files - files
- tasks - tasks
- notifications - notifications
- connectors
## Development Install ## Development Install
@@ -48,7 +69,7 @@ Focused manifest verification:
```bash ```bash
cd /mnt/DATA/git/govoplan-risk-compliance cd /mnt/DATA/git/govoplan-risk-compliance
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
``` ```
## Gitea Workflow ## Gitea Workflow
@@ -59,5 +80,5 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-risk-compliance --apply /mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-risk-compliance --apply
``` ```
+25
View File
@@ -0,0 +1,25 @@
# Risk Compliance interface pattern migration
Risk Compliance uses the platform source-evidence, governed-operation, review-queue, and revisioned-record patterns. It owns sanctions screening and assurance evidence; Connectors owns optional external source acquisition.
## Surfaces
- `risk_compliance.workspace` is the stable route surface and `risk_compliance.navigation` is its navigation entry.
- `risk_compliance.sanctions.sources` lists immutable source snapshots. `risk_compliance.action.import-snapshot` confirms copying acquired connector evidence into the normalized sanctions store.
- `risk_compliance.sanctions.screening` collects the minimum subject data needed for a version-pinned run. `risk_compliance.action.run-screening` creates durable screening evidence.
- `risk_compliance.sanctions.review` is a list-detail review queue. `risk_compliance.review.disposition` appends a legal disposition or time-bounded reusable exception.
- `risk_compliance.assurance.graph` exposes current effective assurance revisions and their bounded relationships. `risk_compliance.assurance.editor` appends object revisions and `risk_compliance.action.connect-assurance` appends typed relationships.
Backend and WebUI manifests publish the same surface identifiers and parent hierarchy so Views can reduce the workspace without private module knowledge.
## Consequences and recovery
Snapshot import is confirmed because it creates immutable normalized evidence. Importing the same content reuses the existing snapshot. A screening run pins that snapshot version and clears the transient subject draft after the backend has accepted the run.
Review dispositions are append-only. Reusable exceptions require an explicit expiry. Assurance edits preserve earlier effective-dated revisions, while system-managed sanctions projections remain read-only. A failed request leaves the draft available; navigating away from a changed screening, disposition, assurance object, or relationship invokes the shared unsaved-change guard.
Unavailable actions remain visible with selection, permission, busy-state, missing-snapshot, system-managed-record, or optional-provider reasons. Contextual help resolves through `govoplan-docs` when enabled and through the hosted fallback otherwise.
## Optional boundaries
Connectors may announce sanctions-source snapshots through its public capability. Risk Compliance does not import connector internals or credentials. Audit, Policy, Records, Files, Tasks, Notifications, Views, and Workflow integrations remain optional and communicate through declared capabilities, interfaces, and stable references.
+83 -11
View File
@@ -12,12 +12,21 @@ Risk and compliance workflows for data protection incidents, DPIAs, compliance c
- data protection incident records - data protection incident records
- audit measures - audit measures
- internal-control evidence - internal-control evidence
- immutable normalized sanctions-list snapshots
- version-pinned screening runs and candidate explanations
- reviewer dispositions, exceptions, and screening freshness
- block, review, and degraded screening-gate decisions
- reusable assurance relationships from obligation through governed object,
risk, control, evidence, finding, corrective measure, and effectiveness
review
## Does Not Own ## Does Not Own
- immutable audit log storage - immutable audit log storage
- records retention engine - records retention engine
- inspection fieldwork - inspection fieldwork
- domain-object lifecycle and corrective execution owned by the affected module
- policy rule evaluation or immutable audit-log storage
## Integration Candidates ## Integration Candidates
@@ -28,20 +37,83 @@ Risk and compliance workflows for data protection incidents, DPIAs, compliance c
- files - files
- tasks - tasks
- notifications - notifications
- connectors
## Seed State ## Sanctions Screening Contract
The current repository state is intentionally small: Connectors owns acquisition and raw source evidence. Risk Compliance imports an
exact connector snapshot, preserves its provenance, normalizes the list, and
owns every legal screening and review decision made from it.
- module manifest and entry point The module provides the versioned
- tenant-level permission definitions `risk_compliance.sanctions_screening` interface and
- manager and viewer role templates `riskCompliance.sanctionsScreeningProvider` capability. A consumer explicitly
- documentation topic describing the module boundary submits:
- Gitea issue workflow templates
- manifest contract test
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. - an immutable list snapshot
- an idempotency key
- the minimum subject data required for comparison
- matching limits and its `block`, `review`, or `degraded` failure policy
## First Implementation Slice The response contains a stable `risk-screening:<run-id>` evidence reference and
a gate decision. Consumers can later check that evidence against a current
subject, an expected or latest source snapshot, and a current policy without
importing this module's internals.
Define risk register, control, evidence, DPIA, incident, measure, and review-cycle concepts. Freshness reasons explicitly distinguish source replacement or age, subject
changes, matcher/normalization/policy changes, incomplete outcomes, and expired
or review-due dispositions. The reconciliation API lists the latest known
screening per subject that can already be proven stale. Subject changes remain
the consuming system's responsibility because Risk Compliance deliberately
stores immutable screening-time snapshots rather than owning each source
record.
Fuzzy candidates always require review. A confirmed match blocks; a set of
independently cleared false positives allows; source or execution uncertainty
uses the consuming module's configured failure policy.
## Horizontal Assurance Graph
Sanctions screening is the first complete assurance vertical, not the whole
module model. Risk Compliance persists the reusable graph:
```text
Obligation -> governed object -> risk -> control -> evidence -> finding -> measure -> effectiveness review
```
Every node and relationship has a stable tenant-local identity, immutable
revision history, effective and recorded time, owner and optional scope,
provenance, legal/policy/evidence references, and optimistic-concurrency
guards. Corrections create a new revision; the historical assertion is not
rewritten or deleted. Governed-object nodes retain only an opaque reference to
the object owned by another module.
The tenant API exposes bounded current-object listing, revision histories,
aggregate counts, and graph traversal. Traversal is intentionally limited to
eight relationships and 500 returned objects. Search indexes only current
revisions, rechecks tenant access at result time, and omits confidential
descriptions. Aggregate responses contain counts, never labels or opaque
references.
Completed sanctions screenings automatically project obligation, party,
exposure risk, version-pinned control, immutable evidence, and finding nodes.
This projection is idempotent and includes unsuccessful or stale screening
outcomes so operational uncertainty remains visible. The non-sanctions test
fixture proves the entire chain through corrective measure and effectiveness
review without coupling the graph to sanctions data.
Policy may advise or block based on assurance state; Audit records events;
Files/Records retain referenced evidence; Tasks/Workflow may coordinate review
and correction. Risk Compliance owns risk, control, finding, and effectiveness
semantics, but the affected domain module remains authoritative for its object
and corrective execution.
### Access, recovery, and retirement
Read, write, and administration use the Risk Compliance workspace scopes and
are always tenant-bound. Generated sanctions evidence additionally keeps the
sanctions permissions on its source workflow. Database backup and restore is
the recovery mechanism for the immutable graph; migration verification covers
both graph tables. Module retirement is destructive and therefore requires a
database snapshot before graph, source, screening, and review evidence is
removed.
+29 -4
View File
@@ -1,8 +1,33 @@
{ {
"name": "@govoplan/risk-compliance", "name": "@govoplan/risk-compliance-webui",
"version": "0.1.7", "version": "0.1.18",
"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.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
} }
+5 -4
View File
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-risk-compliance" name = "govoplan-risk-compliance"
version = "0.1.7" version = "0.1.18"
description = "GovOPlaN Risk Compliance platform module seed." description = "GovOPlaN Risk Compliance platform module seed."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.7", "defusedxml>=0.7.1",
"govoplan-access>=0.1.7", "govoplan-core>=0.1.18",
"govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -22,4 +23,4 @@ where = ["src"]
govoplan_risk_compliance = ["py.typed"] govoplan_risk_compliance = ["py.typed"]
[project.entry-points."govoplan.modules"] [project.entry-points."govoplan.modules"]
"risk-compliance" = "govoplan_risk_compliance.backend.manifest:get_manifest" risk_compliance = "govoplan_risk_compliance.backend.manifest:get_manifest"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
from __future__ import annotations
from govoplan_core.core.sanctions import (
SanctionsScreeningEvidence,
SanctionsScreeningFreshness,
SanctionsScreeningFreshnessRequest,
SanctionsScreeningPolicy,
SanctionsScreeningRequest,
SanctionsScreeningResult,
SanctionsScreeningSubject,
)
from govoplan_risk_compliance.backend.screening import (
ScreeningFreshnessAssessment,
ScreeningPolicy,
ScreeningSubject,
assess_screening_freshness,
run_screening,
screening_evidence_ref,
)
class RiskComplianceSanctionsScreeningProvider:
def request_screening(
self,
session: object,
principal: object,
request: SanctionsScreeningRequest,
) -> SanctionsScreeningResult:
policy = _local_policy(request.policy)
subject = _local_subject(request.subject)
run, created = run_screening(
session,
principal,
list_snapshot_id=request.list_snapshot_id,
idempotency_key=request.idempotency_key,
subject=subject,
policy=policy,
)
assessment = assess_screening_freshness(
session,
principal,
evidence_ref=screening_evidence_ref(run.id),
current_subject=subject,
expected_list_snapshot_id=request.list_snapshot_id,
policy=policy,
failure_policy=request.policy.failure_policy,
)
freshness = _freshness_response(assessment)
return SanctionsScreeningResult(
evidence=freshness.evidence,
freshness=freshness,
created=created,
)
def check_freshness(
self,
session: object,
principal: object,
request: SanctionsScreeningFreshnessRequest,
) -> SanctionsScreeningFreshness:
assessment = assess_screening_freshness(
session,
principal,
evidence_ref=request.evidence_ref,
current_subject=(
_local_subject(request.current_subject)
if request.current_subject is not None
else None
),
expected_list_snapshot_id=(
request.expected_list_snapshot_id
),
policy=_local_policy(request.policy),
failure_policy=request.policy.failure_policy,
)
return _freshness_response(assessment)
def _local_subject(
value: SanctionsScreeningSubject,
) -> ScreeningSubject:
return ScreeningSubject(
subject_type=value.subject_type,
primary_name=value.primary_name,
subject_ref=value.subject_ref,
aliases=tuple(value.aliases),
identifiers=tuple(
dict(item)
for item in value.identifiers
),
dates=tuple(value.dates),
addresses=tuple(
dict(item)
for item in value.addresses
),
)
def _local_policy(
value: SanctionsScreeningPolicy,
) -> ScreeningPolicy:
return ScreeningPolicy(
fuzzy_threshold=value.fuzzy_threshold,
max_snapshot_age_days=value.max_snapshot_age_days,
max_candidates=value.max_candidates,
)
def _freshness_response(
value: ScreeningFreshnessAssessment,
) -> SanctionsScreeningFreshness:
run = value.run
evidence = SanctionsScreeningEvidence(
ref=value.evidence_ref,
run_id=run.id,
outcome=run.outcome,
candidate_count=run.candidate_count,
list_snapshot_id=run.list_snapshot_id,
source_version=run.list_snapshot.source_version,
subject_fingerprint=run.subject_snapshot.fingerprint,
matcher_version=run.matcher_version,
normalization_version=run.normalization_version,
policy_version=run.policy_version,
completed_at=run.completed_at,
)
return SanctionsScreeningFreshness(
evidence=evidence,
fresh=value.fresh,
reasons=value.reasons,
checked_at=value.checked_at,
current_list_snapshot_id=value.current_list_snapshot_id,
gate_decision=value.gate_decision,
gate_reasons=value.gate_reasons,
)
__all__ = ["RiskComplianceSanctionsScreeningProvider"]
@@ -0,0 +1,2 @@
"""Risk Compliance persistence models."""
File diff suppressed because it is too large Load Diff
+502 -26
View File
@@ -1,14 +1,68 @@
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
MODULE_ID = "risk-compliance" 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,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.sanctions import (
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
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_NAME = "Risk Compliance" MODULE_NAME = "Risk Compliance"
MODULE_VERSION = "0.1.7" MODULE_VERSION = "0.1.18"
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 +71,96 @@ OPTIONAL_DEPENDENCIES = (
"files", "files",
"tasks", "tasks",
"notifications", "notifications",
"connectors",
)
_PERSISTENT_MODELS = (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskScreeningException,
RiskScreeningDisposition,
RiskScreeningCandidate,
RiskScreeningRun,
RiskScreeningSubjectSnapshot,
RiskSanctionsAddress,
RiskSanctionsDate,
RiskSanctionsIdentifier,
RiskSanctionsAlias,
RiskSanctionsEntry,
RiskSanctionsListSnapshot,
)
ARCHITECTURE = ModuleArchitectureDeclaration(
layer="governance_accountability",
kind="governance",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_assurance_graph.py",
summary=(
"Exercises tenant-safe immutable graph revisions, bounded traversal, "
"sanctions projection, synthetic controls, ACL, and search."
),
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_sanctions_screening.py",
summary=(
"Exercises immutable sanctions evidence, deterministic matching, "
"review, exceptions, and freshness gates."
),
),
ModuleMaturityEvidence(
kind="migration",
reference="tests/test_migrations.py",
summary=(
"Exercises the persistent sanctions screening and assurance "
"graph schemas."
),
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",
summary="Defines assurance ownership and integration boundaries.",
),
),
known_limits=(
"The assurance graph provides generic governance primitives; domain modules still own corrective execution.",
"Cross-tenant aggregate assurance is intentionally not exposed by the tenant API.",
),
supported_authority_modes=(
"native_authoritative",
"external_mirror",
"governance_overlay",
"linked_reference",
),
owned_concepts=(
"risk and control evaluation",
"sanctions screening runs",
"candidate review and dispositions",
"compliance findings and assurance review",
),
non_owned_concepts=(
"external source transport and credentials",
"immutable audit event storage",
"policy rule evaluation",
"governed domain objects and corrective execution",
),
documentation=ModuleArchitectureDocumentation(
migration=("tests/test_migrations.py",),
upgrade=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
recovery=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
security=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
operations=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
),
) )
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 +175,223 @@ 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=("Manage compliance workflows and administer sanctions screening."),
permissions=(READ_SCOPE, WRITE_SCOPE), 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 _sanctions_screening_provider(_context):
from govoplan_risk_compliance.backend.capabilities import (
RiskComplianceSanctionsScreeningProvider,
)
return RiskComplianceSanctionsScreeningProvider()
def _assurance_search_source(context):
from govoplan_risk_compliance.backend.search_source import (
create_risk_assurance_search_source,
)
return create_risk_assurance_search_source(context)
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()
),
"risk_assurance_nodes": (
session.query(RiskAssuranceNode)
.filter(
RiskAssuranceNode.tenant_id == tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
.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. "
"The broader module direction links obligations, governed object "
"references, risks, controls, evidence, findings, corrective "
"measures, and effectiveness reviews without copying the governed "
"domain object or replacing Policy and Audit."
), ),
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",
),
DocumentationLink(
label="Interface pattern migration",
href=(
"govoplan-risk-compliance/docs/INTERFACE_PATTERN_MIGRATION.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."
),
"assurance_domain_model": [
"obligation",
"governed object reference",
"risk",
"control",
"evidence",
"finding",
"corrective measure",
"effectiveness review",
],
"assurance_graph": (
"Every node and edge is effective-dated, revisioned, tenant-scoped, "
"and linked through opaque governed-object references."
),
"help_contexts": [
"risk_compliance.workspace",
"risk_compliance.sanctions.sources",
"risk_compliance.action.import-snapshot",
"risk_compliance.sanctions.screening",
"risk_compliance.action.run-screening",
"risk_compliance.sanctions.review",
"risk_compliance.review.disposition",
"risk_compliance.assurance.graph",
"risk_compliance.assurance.editor",
"risk_compliance.action.connect-assurance",
"risk_compliance.state.source-unavailable",
"risk_compliance.state.read-only",
],
"consequence_classes": {
"import_snapshot": "copy connector evidence into an immutable normalized sanctions-list snapshot",
"run_screening": "create immutable version-pinned screening evidence from the minimum submitted subject data",
"record_disposition": "append an evidence-backed legal review disposition that is not edited in place",
"record_exception": "append a time-bounded subject-and-entry exception with explicit expiry",
"revise_assurance_object": "append a new effective-dated revision while preserving prior evidence",
"connect_assurance_objects": "append a governed typed relationship between assurance objects",
},
}, },
), ),
) )
@@ -91,12 +402,177 @@ 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,
),
provides_interfaces=(
ModuleInterfaceProvider(
name="risk_compliance.sanctions_screening",
version="1.0.0",
),
),
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,
capability_factories={
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider),
},
search_sources=(
SearchSourceProviderRegistration(
id="risk_compliance.assurance",
factory=_assurance_search_source,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/risk-compliance-webui",
routes=(
FrontendRoute(
path="/risk-compliance",
component="RiskCompliancePage",
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
order=115,
surface_id="risk_compliance.workspace",
),
),
nav_items=(
NavItem(
path="/risk-compliance",
label="Risk Compliance",
icon="shield-check",
required_any=(READ_SCOPE, 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",
parent_id="risk_compliance.workspace",
order=20,
),
ViewSurface(
id="risk_compliance.sanctions.screening",
module_id=MODULE_ID,
kind="section",
label="Sanctions screening",
parent_id="risk_compliance.workspace",
order=30,
),
ViewSurface(
id="risk_compliance.sanctions.review",
module_id=MODULE_ID,
kind="section",
label="Sanctions review queue",
parent_id="risk_compliance.workspace",
order=40,
),
ViewSurface(
id="risk_compliance.assurance.graph",
module_id=MODULE_ID,
kind="section",
label="Assurance graph",
parent_id="risk_compliance.workspace",
order=50,
),
ViewSurface(
id="risk_compliance.action.import-snapshot",
module_id=MODULE_ID,
kind="action",
label="Import sanctions source snapshot",
parent_id="risk_compliance.sanctions.sources",
order=60,
),
ViewSurface(
id="risk_compliance.action.run-screening",
module_id=MODULE_ID,
kind="action",
label="Run sanctions screening",
parent_id="risk_compliance.sanctions.screening",
order=70,
),
ViewSurface(
id="risk_compliance.review.disposition",
module_id=MODULE_ID,
kind="dialog",
label="Record screening disposition",
parent_id="risk_compliance.sanctions.review",
order=80,
),
ViewSurface(
id="risk_compliance.assurance.editor",
module_id=MODULE_ID,
kind="dialog",
label="Assurance object editor",
parent_id="risk_compliance.assurance.graph",
order=90,
),
ViewSurface(
id="risk_compliance.action.connect-assurance",
module_id=MODULE_ID,
kind="action",
label="Connect assurance objects",
parent_id="risk_compliance.assurance.graph",
order=100,
),
),
),
tenant_summary_providers=(_tenant_summary,),
architecture=ARCHITECTURE,
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 assurance graph, "
"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],
)
@@ -0,0 +1,180 @@
"""Add the effective-dated horizontal assurance graph.
Revision ID: b9c0d1e2f3a4
Revises: a8b9c0d1e2f3
Create Date: 2026-08-01
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b9c0d1e2f3a4"
down_revision = "a8b9c0d1e2f3"
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_assurance_nodes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("stable_id", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=40), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("owner_ref", sa.String(length=500), nullable=False),
sa.Column("scope_ref", sa.String(length=500), nullable=True),
sa.Column("governed_object_ref", sa.String(length=1000), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("legal_basis_refs", sa.JSON(), nullable=False),
sa.Column("policy_refs", sa.JSON(), nullable=False),
sa.Column("evidence_refs", sa.JSON(), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["risk_assurance_nodes.id"],
name=op.f(
"fk_risk_assurance_nodes_previous_revision_id_"
"risk_assurance_nodes"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_risk_assurance_nodes")),
sa.UniqueConstraint(
"tenant_id",
"stable_id",
"revision",
name="uq_risk_assurance_node_revision",
),
)
_indexes(
"risk_assurance_nodes",
(
"tenant_id",
"stable_id",
"kind",
"state",
"owner_ref",
"scope_ref",
"governed_object_ref",
"valid_from",
"valid_to",
"recorded_at",
"superseded_at",
"classification",
"created_by",
),
)
op.create_index(
"ix_risk_assurance_node_current",
"risk_assurance_nodes",
["tenant_id", "kind", "state", "superseded_at"],
)
op.create_index(
"ix_risk_assurance_node_governed_object",
"risk_assurance_nodes",
["tenant_id", "governed_object_ref", "superseded_at"],
)
op.create_table(
"risk_assurance_edges",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("stable_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("source_node_ref", sa.String(length=255), nullable=False),
sa.Column("target_node_ref", sa.String(length=255), nullable=False),
sa.Column("relation", sa.String(length=50), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("owner_ref", sa.String(length=500), nullable=False),
sa.Column("scope_ref", sa.String(length=500), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("legal_basis_refs", sa.JSON(), nullable=False),
sa.Column("policy_refs", sa.JSON(), nullable=False),
sa.Column("evidence_refs", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["risk_assurance_edges.id"],
name=op.f(
"fk_risk_assurance_edges_previous_revision_id_"
"risk_assurance_edges"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_risk_assurance_edges")),
sa.UniqueConstraint(
"tenant_id",
"stable_id",
"revision",
name="uq_risk_assurance_edge_revision",
),
)
_indexes(
"risk_assurance_edges",
(
"tenant_id",
"stable_id",
"source_node_ref",
"target_node_ref",
"relation",
"state",
"owner_ref",
"scope_ref",
"valid_from",
"valid_to",
"recorded_at",
"superseded_at",
"created_by",
),
)
op.create_index(
"ix_risk_assurance_edge_source",
"risk_assurance_edges",
["tenant_id", "source_node_ref", "state", "superseded_at"],
)
op.create_index(
"ix_risk_assurance_edge_target",
"risk_assurance_edges",
["tenant_id", "target_node_ref", "state", "superseded_at"],
)
def downgrade() -> None:
op.drop_table("risk_assurance_edges")
op.drop_table("risk_assurance_nodes")
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],
)
@@ -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",
]
@@ -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",
]
@@ -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",
]
File diff suppressed because it is too large Load Diff
@@ -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",
]
@@ -0,0 +1,496 @@
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 ScreeningFreshnessCheckRequest(BaseModel):
current_subject: ScreeningSubjectInput | None = None
expected_list_snapshot_id: str | None = Field(
default=None,
max_length=36,
)
policy: ScreeningPolicyInput | None = None
failure_policy: Literal[
"block",
"review",
"degraded",
] = "block"
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
evidence_ref: 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
evidence_ref: 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 ScreeningFreshnessResponse(BaseModel):
evidence_ref: str
run: ScreeningRunSummaryResponse
fresh: bool
reasons: list[str]
checked_at: datetime
current_list_snapshot_id: str | None
gate_decision: Literal[
"allow",
"block",
"review",
"degraded",
]
gate_reasons: list[str]
class RescreeningRequirementListResponse(BaseModel):
requirements: list[ScreeningFreshnessResponse]
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]
AssuranceNodeKind = Literal[
"obligation",
"governed_object",
"risk",
"control",
"evidence",
"finding",
"corrective_measure",
"effectiveness_review",
]
AssuranceEdgeRelation = Literal[
"applies_to",
"exposes_risk",
"mitigated_by",
"evidenced_by",
"results_in",
"addressed_by",
"reviewed_by",
]
class AssuranceNodeWrite(BaseModel):
stable_id: str = Field(min_length=1, max_length=255)
kind: AssuranceNodeKind
label: str = Field(min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=20_000)
state: str = Field(min_length=1, max_length=40)
owner_ref: str = Field(min_length=1, max_length=500)
scope_ref: str | None = Field(default=None, max_length=500)
governed_object_ref: str | None = Field(default=None, max_length=1000)
valid_from: datetime
valid_to: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
legal_basis_refs: list[str] = Field(default_factory=list, max_length=100)
policy_refs: list[str] = Field(default_factory=list, max_length=100)
evidence_refs: list[str] = Field(default_factory=list, max_length=100)
classification: str = Field(default="internal", min_length=1, max_length=50)
class AssuranceNodeRevisionRequest(AssuranceNodeWrite):
expected_revision: int = Field(ge=1)
class AssuranceNodeResponse(AssuranceNodeWrite):
id: str
revision: int
previous_revision_id: str | None
recorded_at: datetime
superseded_at: datetime | None
created_by: str | None
created_at: datetime
updated_at: datetime
class AssuranceNodeListResponse(BaseModel):
nodes: list[AssuranceNodeResponse]
class AssuranceEdgeWrite(BaseModel):
stable_id: str = Field(min_length=1, max_length=255)
source_node_ref: str = Field(min_length=1, max_length=255)
target_node_ref: str = Field(min_length=1, max_length=255)
relation: AssuranceEdgeRelation
state: Literal["active", "suspended", "retired"] = "active"
owner_ref: str = Field(min_length=1, max_length=500)
scope_ref: str | None = Field(default=None, max_length=500)
valid_from: datetime
valid_to: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
legal_basis_refs: list[str] = Field(default_factory=list, max_length=100)
policy_refs: list[str] = Field(default_factory=list, max_length=100)
evidence_refs: list[str] = Field(default_factory=list, max_length=100)
class AssuranceEdgeRevisionRequest(AssuranceEdgeWrite):
expected_revision: int = Field(ge=1)
class AssuranceEdgeResponse(AssuranceEdgeWrite):
id: str
revision: int
previous_revision_id: str | None
recorded_at: datetime
superseded_at: datetime | None
created_by: str | None
created_at: datetime
updated_at: datetime
class AssuranceEdgeListResponse(BaseModel):
edges: list[AssuranceEdgeResponse]
class AssuranceGraphResponse(BaseModel):
root_ref: str
nodes: list[AssuranceNodeResponse]
edges: list[AssuranceEdgeResponse]
truncated: bool
class AssuranceSummaryResponse(BaseModel):
node_count: int
edge_count: int
by_kind: dict[str, int]
by_state: dict[str, int]
__all__ = [
"AssuranceEdgeListResponse",
"AssuranceEdgeResponse",
"AssuranceEdgeRevisionRequest",
"AssuranceEdgeWrite",
"AssuranceGraphResponse",
"AssuranceNodeListResponse",
"AssuranceNodeResponse",
"AssuranceNodeRevisionRequest",
"AssuranceNodeWrite",
"AssuranceSummaryResponse",
"CandidateDetailResponse",
"CandidateResponse",
"ConnectorSnapshotListResponse",
"ConnectorSnapshotResponse",
"DispositionCreateRequest",
"DispositionCreateResponse",
"DispositionListResponse",
"DispositionResponse",
"ListSnapshotImportRequest",
"ListSnapshotImportResponse",
"ListSnapshotListResponse",
"ListSnapshotResponse",
"ReviewQueueItemResponse",
"ReviewQueueResponse",
"RescreeningRequirementListResponse",
"ScreeningFreshnessCheckRequest",
"ScreeningFreshnessResponse",
"ScreeningRunCreateRequest",
"ScreeningRunCreateResponse",
"ScreeningRunListResponse",
"ScreeningRunResponse",
"ScreeningRunSummaryResponse",
"SubjectSnapshotResponse",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_risk_compliance.backend.db.models import RiskAssuranceNode
from govoplan_risk_compliance.backend.permissions import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
PROVIDER_ID = "risk_compliance.assurance"
RESOURCE_TYPE = "risk_assurance_node"
class RiskAssuranceSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="risk_compliance",
resource_type=RESOURCE_TYPE,
label="Assurance graph",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
db = _session(session)
if (
request.provider_id != PROVIDER_ID
or request.resource_type != RESOURCE_TYPE
):
raise ValueError("Unsupported Risk Compliance search source.")
statement = select(RiskAssuranceNode).where(
RiskAssuranceNode.tenant_id == request.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
if request.cursor:
statement = statement.where(
RiskAssuranceNode.stable_id > request.cursor
)
rows = list(
db.scalars(
statement.order_by(RiskAssuranceNode.stable_id).limit(
request.limit + 1
)
)
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(RiskAssuranceNode.recorded_at)).where(
RiskAssuranceNode.tenant_id == request.tenant_id,
RiskAssuranceNode.superseded_at.is_(None),
)
)
return SearchBackfillPage(
documents=tuple(_document(item) for item in selected),
next_cursor=(
selected[-1].stable_id
if has_more and selected
else None
),
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {request.reference.key: False for request in requests}
if not isinstance(principal, ApiPrincipal) or not any(
principal.has(scope)
for scope in (READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
):
return decisions
valid = tuple(
request
for request in requests
if request.reference.tenant_id == principal.tenant_id
and request.reference.module_id == "risk_compliance"
and request.reference.resource_type == RESOURCE_TYPE
)
if not valid:
return decisions
ids = {request.reference.resource_id for request in valid}
existing = set(
_session(session).scalars(
select(RiskAssuranceNode.stable_id).where(
RiskAssuranceNode.tenant_id == principal.tenant_id,
RiskAssuranceNode.stable_id.in_(ids),
RiskAssuranceNode.superseded_at.is_(None),
)
)
)
for request in valid:
decisions[request.reference.key] = (
request.reference.resource_id in existing
)
return decisions
def create_risk_assurance_search_source(
_context: ModuleContext,
) -> RiskAssuranceSearchSource:
return RiskAssuranceSearchSource()
def _document(item: RiskAssuranceNode) -> SearchDocument:
return SearchDocument(
tenant_id=item.tenant_id,
module_id="risk_compliance",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=item.stable_id,
title=item.label,
url=(
"/risk-compliance?view=assurance&node="
f"{quote(item.stable_id, safe='')}"
),
summary=(
item.description
if item.classification in {"public", "internal"}
else None
),
keywords=(item.kind, item.state, item.classification),
visibility="tenant",
source_revision=str(item.revision),
source_updated_at=item.recorded_at,
metadata={
"kind": item.kind,
"state": item.state,
"classification": item.classification,
"revision": item.revision,
},
requires_authorization_recheck=True,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError(
"Risk assurance search requires a SQLAlchemy session."
)
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"RiskAssuranceSearchSource",
"create_risk_assurance_search_source",
]
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
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.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.db.base import Base
from govoplan_risk_compliance.backend.assurance import (
AssuranceEdgeInput,
AssuranceNodeInput,
RiskAssuranceAccessError,
RiskAssuranceConflictError,
RiskAssuranceError,
RiskAssuranceNotFoundError,
assurance_graph,
assurance_summary,
create_assurance_edge,
create_assurance_node,
list_assurance_edge_history,
list_assurance_node_history,
list_assurance_nodes,
revise_assurance_edge,
revise_assurance_node,
)
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
)
from govoplan_risk_compliance.backend.permissions import (
READ_SCOPE,
WRITE_SCOPE,
)
from govoplan_risk_compliance.backend.search_source import (
PROVIDER_ID,
RESOURCE_TYPE,
RiskAssuranceSearchSource,
)
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
TABLES = (
RiskAssuranceNode.__table__,
RiskAssuranceEdge.__table__,
)
def principal(
tenant_id: str = "tenant-1",
*,
scopes: tuple[str, ...] = (READ_SCOPE, WRITE_SCOPE),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=f"account-{tenant_id}",
membership_id=f"membership-{tenant_id}",
tenant_id=tenant_id,
scopes=frozenset(scopes),
),
account=SimpleNamespace(id=f"account-{tenant_id}"),
user=SimpleNamespace(id=f"account-{tenant_id}"),
)
class AssuranceGraphTests(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)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_non_sanctions_control_uses_complete_revisioned_graph(self) -> None:
node_specs = (
("retention-obligation", "obligation", "active"),
("customer-register", "governed_object", "active"),
("over-retention-risk", "risk", "identified"),
("retention-control", "control", "implemented"),
("retention-evidence", "evidence", "current"),
("retention-finding", "finding", "open"),
("retention-measure", "corrective_measure", "planned"),
("retention-review", "effectiveness_review", "pending"),
)
nodes = {}
for stable_id, kind, state in node_specs:
nodes[stable_id] = create_assurance_node(
self.session,
principal(),
value=AssuranceNodeInput(
stable_id=stable_id,
kind=kind,
label=stable_id.replace("-", " ").title(),
state=state,
owner_ref="organizations:function:data-governance",
scope_ref="datasource:customer-register",
governed_object_ref=(
"datasources:customer-register"
if kind == "governed_object"
else None
),
valid_from=NOW,
provenance={"fixture": "non-sanctions"},
legal_basis_refs=("law:retention:2026",),
),
)
relations = (
("retention-obligation", "applies_to", "customer-register"),
("customer-register", "exposes_risk", "over-retention-risk"),
("over-retention-risk", "mitigated_by", "retention-control"),
("retention-control", "evidenced_by", "retention-evidence"),
("retention-evidence", "results_in", "retention-finding"),
("retention-finding", "addressed_by", "retention-measure"),
("retention-measure", "reviewed_by", "retention-review"),
)
edges = []
for index, (source, relation, target) in enumerate(relations, start=1):
edges.append(
create_assurance_edge(
self.session,
principal(),
value=AssuranceEdgeInput(
stable_id=f"retention-edge-{index}",
source_node_ref=source,
target_node_ref=target,
relation=relation,
owner_ref="organizations:function:data-governance",
valid_from=NOW,
provenance={"fixture": "non-sanctions"},
),
)
)
self.session.commit()
graph = assurance_graph(
self.session,
principal(),
root_ref="retention-obligation",
max_depth=8,
limit=100,
)
summary = assurance_summary(self.session, principal())
self.assertEqual(8, len(graph.nodes))
self.assertEqual(7, len(graph.edges))
self.assertFalse(graph.truncated)
self.assertEqual(8, summary["node_count"])
self.assertEqual(7, summary["edge_count"])
self.assertEqual(1, summary["by_kind"]["effectiveness_review"])
self.assertEqual("control", nodes["retention-control"].kind)
self.assertEqual("reviewed_by", edges[-1].relation)
def test_node_and_edge_revisions_are_immutable_and_occ_guarded(self) -> None:
risk = self._node("risk-1", "risk", "identified")
control = self._node("control-1", "control", "implemented")
edge = create_assurance_edge(
self.session,
principal(),
value=AssuranceEdgeInput(
stable_id="edge-1",
source_node_ref=risk.stable_id,
target_node_ref=control.stable_id,
relation="mitigated_by",
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
revised = revise_assurance_node(
self.session,
principal(),
stable_id=risk.stable_id,
expected_revision=1,
value=AssuranceNodeInput(
stable_id=risk.stable_id,
kind="risk",
label="Risk 1",
state="assessed",
owner_ref="function:risk-owner",
valid_from=NOW,
evidence_refs=("evidence:assessment-1",),
),
)
revised_edge = revise_assurance_edge(
self.session,
principal(),
stable_id=edge.stable_id,
expected_revision=1,
value=AssuranceEdgeInput(
stable_id=edge.stable_id,
source_node_ref=risk.stable_id,
target_node_ref=control.stable_id,
relation="mitigated_by",
state="suspended",
owner_ref="function:risk-owner",
valid_from=NOW,
evidence_refs=("evidence:suspension-1",),
),
)
self.session.commit()
self.assertEqual(2, revised.revision)
self.assertEqual(2, revised_edge.revision)
self.assertEqual(
[2, 1],
[
item.revision
for item in list_assurance_node_history(
self.session,
principal(),
stable_id="risk-1",
)
],
)
self.assertEqual(
[2, 1],
[
item.revision
for item in list_assurance_edge_history(
self.session,
principal(),
stable_id="edge-1",
)
],
)
with self.assertRaisesRegex(
RiskAssuranceConflictError,
"current revision is 2",
):
revise_assurance_node(
self.session,
principal(),
stable_id="risk-1",
expected_revision=1,
value=AssuranceNodeInput(
stable_id="risk-1",
kind="risk",
label="Risk 1",
state="closed",
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
def test_access_and_tenant_boundaries_are_enforced(self) -> None:
self._node("risk-1", "risk", "identified")
self.session.commit()
self.assertEqual(
(),
list_assurance_nodes(self.session, principal("tenant-2")),
)
with self.assertRaises(RiskAssuranceNotFoundError):
assurance_graph(
self.session,
principal("tenant-2"),
root_ref="risk-1",
)
with self.assertRaises(RiskAssuranceAccessError):
list_assurance_nodes(
self.session,
principal(scopes=()),
)
def test_programmatic_inputs_enforce_text_and_provenance_bounds(self) -> None:
with self.assertRaisesRegex(RiskAssuranceError, "description is limited"):
AssuranceNodeInput(
stable_id="risk-oversized-description",
kind="risk",
label="Oversized risk",
state="identified",
owner_ref="function:risk-owner",
valid_from=NOW,
description="x" * 20_001,
)
with self.assertRaisesRegex(RiskAssuranceError, "provenance is limited"):
AssuranceNodeInput(
stable_id="risk-oversized-provenance",
kind="risk",
label="Oversized provenance",
state="identified",
owner_ref="function:risk-owner",
valid_from=NOW,
provenance={"payload": "x" * 100_001},
)
def test_relation_shape_is_validated(self) -> None:
self._node("risk-1", "risk", "identified")
self._node("evidence-1", "evidence", "current")
with self.assertRaisesRegex(
RiskAssuranceConflictError,
"requires risk -> control",
):
create_assurance_edge(
self.session,
principal(),
value=AssuranceEdgeInput(
stable_id="invalid-edge",
source_node_ref="risk-1",
target_node_ref="evidence-1",
relation="mitigated_by",
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
def test_search_backfill_and_authorization_are_tenant_safe(self) -> None:
self._node("risk-1", "risk", "identified")
self.session.commit()
provider = RiskAssuranceSearchSource()
page = provider.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
request = SearchAuthorizationRequest(
reference=SearchResourceReference(
tenant_id="tenant-1",
module_id="risk_compliance",
resource_type=RESOURCE_TYPE,
resource_id="risk-1",
),
source_revision="1",
)
self.assertEqual(1, len(page.documents))
self.assertTrue(
provider.authorize(
self.session,
principal(),
requests=(request,),
)[request.reference.key]
)
self.assertFalse(
provider.authorize(
self.session,
principal("tenant-2"),
requests=(request,),
)[request.reference.key]
)
def _node(
self,
stable_id: str,
kind: str,
state: str,
) -> RiskAssuranceNode:
return create_assurance_node(
self.session,
principal(),
value=AssuranceNodeInput(
stable_id=stable_id,
kind=kind,
label=stable_id.replace("-", " ").title(),
state=state,
owner_ref="function:risk-owner",
valid_from=NOW,
),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,100 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_risk_compliance.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class RiskComplianceInterfaceDocumentationContractTests(unittest.TestCase):
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
expected = {
"risk_compliance.sanctions.sources",
"risk_compliance.sanctions.screening",
"risk_compliance.sanctions.review",
"risk_compliance.assurance.graph",
"risk_compliance.action.import-snapshot",
"risk_compliance.action.run-screening",
"risk_compliance.review.disposition",
"risk_compliance.assurance.editor",
"risk_compliance.action.connect-assurance",
}
self.assertEqual(expected, set(surfaces))
self.assertEqual(
"risk_compliance.workspace",
frontend.routes[0].surface_id, # type: ignore[union-attr]
)
for surface_id in (
"risk_compliance.sanctions.sources",
"risk_compliance.sanctions.screening",
"risk_compliance.sanctions.review",
"risk_compliance.assurance.graph",
):
self.assertEqual("risk_compliance.workspace", surfaces[surface_id].parent_id)
self.assertEqual(
"risk_compliance.sanctions.sources",
surfaces["risk_compliance.action.import-snapshot"].parent_id,
)
self.assertEqual(
"risk_compliance.sanctions.screening",
surfaces["risk_compliance.action.run-screening"].parent_id,
)
self.assertEqual(
"risk_compliance.sanctions.review",
surfaces["risk_compliance.review.disposition"].parent_id,
)
self.assertEqual(
"risk_compliance.assurance.graph",
surfaces["risk_compliance.assurance.editor"].parent_id,
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
topic = topics["risk_compliance.module-boundary"]
for context in (
"risk_compliance.action.import-snapshot",
"risk_compliance.action.run-screening",
"risk_compliance.review.disposition",
"risk_compliance.assurance.editor",
"risk_compliance.action.connect-assurance",
):
self.assertIn(context, topic.metadata["help_contexts"])
for consequence in (
"import_snapshot",
"run_screening",
"record_disposition",
"record_exception",
"revise_assurance_object",
"connect_assurance_objects",
):
self.assertIn(consequence, topic.metadata["consequence_classes"])
def test_webui_uses_shared_governed_operation_patterns(self) -> None:
page = (
REPO_ROOT
/ "webui/src/features/riskCompliance/RiskCompliancePage.tsx"
).read_text(encoding="utf-8")
module = (REPO_ROOT / "webui/src/module.ts").read_text(encoding="utf-8")
for component in (
"ActionBlockerHint",
"ConfirmDialog",
"DocumentationHelpLink",
"MetricCard",
"SelectionList",
"useUnsavedDraftGuard",
):
self.assertIn(component, page)
self.assertIn("risk_compliance.assurance.graph", module)
self.assertIn("risk_compliance.action.connect-assurance", module)
if __name__ == "__main__":
unittest.main()
+61 -10
View File
@@ -2,22 +2,73 @@ 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_core.core.sanctions import (
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
SanctionsScreeningProvider,
)
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("vertical_slice", manifest.architecture.maturity)
self.assertIn(
"governance_overlay",
manifest.architecture.supported_authority_modes,
)
self.assertEqual(
"connectors.sanctions_snapshots",
manifest.requires_interfaces[0].name,
)
self.assertTrue(manifest.requires_interfaces[0].optional)
self.assertEqual(
{"risk_compliance.sanctions_screening"},
{
item.name
for item in manifest.provides_interfaces
},
)
capability = manifest.capability_factories[
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
](None)
self.assertIsInstance(capability, SanctionsScreeningProvider)
if __name__ == "__main__": if __name__ == "__main__":
+59
View File
@@ -0,0 +1,59 @@
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(
"b9c0d1e2f3a4",
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,
)
self.assertIn("risk_assurance_nodes", tables)
self.assertIn("risk_assurance_edges", tables)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+533
View File
@@ -0,0 +1,533 @@
from __future__ import annotations
from dataclasses import replace
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,
SanctionsScreeningFreshnessRequest,
SanctionsScreeningPolicy,
SanctionsScreeningRequest,
SanctionsScreeningSubject,
SanctionsSnapshotPayload,
SanctionsSnapshotReference,
)
from govoplan_core.db.base import Base, utcnow
from govoplan_risk_compliance.backend.db.models import (
RiskAssuranceEdge,
RiskAssuranceNode,
RiskSanctionsAddress,
RiskSanctionsAlias,
RiskSanctionsDate,
RiskSanctionsEntry,
RiskSanctionsIdentifier,
RiskSanctionsListSnapshot,
RiskScreeningCandidate,
RiskScreeningDisposition,
RiskScreeningException,
RiskScreeningRun,
RiskScreeningSubjectSnapshot,
)
from govoplan_risk_compliance.backend.capabilities import (
RiskComplianceSanctionsScreeningProvider,
)
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,
assess_screening_freshness,
list_rescreening_requirements,
run_screening,
screening_evidence_ref,
)
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 = (
RiskAssuranceNode.__table__,
RiskAssuranceEdge.__table__,
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"]
)
self.assertEqual(
6,
self.session.query(RiskAssuranceNode)
.filter(
RiskAssuranceNode.tenant_id == "tenant-1",
RiskAssuranceNode.superseded_at.is_(None),
)
.count(),
)
self.assertEqual(
5,
self.session.query(RiskAssuranceEdge)
.filter(
RiskAssuranceEdge.tenant_id == "tenant-1",
RiskAssuranceEdge.superseded_at.is_(None),
)
.count(),
)
item_id = item.id
self.session.commit()
self.session.expire_all()
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)
self.assertEqual(6, self.session.query(RiskAssuranceNode).count())
self.assertEqual(5, self.session.query(RiskAssuranceEdge).count())
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_freshness_reconciles_source_subject_matcher_and_policy(self) -> None:
item, _ = run_screening(
self.session,
principal(),
list_snapshot_id=self.list_snapshot.id,
idempotency_key="freshness-1",
subject=ScreeningSubject(
subject_type="person",
primary_name="No Match",
subject_ref="person-1",
),
)
item.matcher_version = "sanctions-matcher-legacy"
self.provider.snapshot = replace(
self.provider.snapshot,
ref="sanctions-snapshot:fixture-v2",
source_version="fixture-v2",
acquired_at=utcnow(),
connector_run_id="run-fixture-v2",
)
current_snapshot, created = import_connector_snapshot(
self.session,
principal(),
registry=self.registry,
connector_snapshot_ref=self.provider.snapshot.ref,
)
self.assertTrue(created)
assessment = assess_screening_freshness(
self.session,
principal(),
evidence_ref=screening_evidence_ref(item.id),
current_subject=ScreeningSubject(
subject_type="person",
primary_name="Changed Subject",
subject_ref="person-1",
),
policy=ScreeningPolicy(fuzzy_threshold=0.9),
failure_policy="degraded",
)
self.assertFalse(assessment.fresh)
self.assertEqual(current_snapshot.id, assessment.current_list_snapshot_id)
self.assertEqual("degraded", assessment.gate_decision)
self.assertIn("source_snapshot_changed", assessment.reasons)
self.assertIn("subject_changed", assessment.reasons)
self.assertIn("matcher_version_changed", assessment.reasons)
self.assertIn("policy_changed", assessment.reasons)
requirements = list_rescreening_requirements(
self.session,
principal(),
)
self.assertEqual([item.id], [value.run.id for value in requirements])
def test_versioned_capability_returns_stable_gate_evidence(self) -> None:
provider = RiskComplianceSanctionsScreeningProvider()
request = SanctionsScreeningRequest(
list_snapshot_id=self.list_snapshot.id,
idempotency_key="capability-1",
subject=SanctionsScreeningSubject(
subject_type="entity",
primary_name="No Match Company",
subject_ref="entity-1",
),
policy=SanctionsScreeningPolicy(
failure_policy="review",
),
)
result = provider.request_screening(
self.session,
principal(),
request,
)
replay = provider.request_screening(
self.session,
principal(),
request,
)
changed = provider.check_freshness(
self.session,
principal(),
SanctionsScreeningFreshnessRequest(
evidence_ref=result.evidence.ref,
current_subject=SanctionsScreeningSubject(
subject_type="entity",
primary_name="Changed Company",
subject_ref="entity-1",
),
expected_list_snapshot_id=self.list_snapshot.id,
policy=request.policy,
),
)
self.assertTrue(result.created)
self.assertFalse(replay.created)
self.assertEqual(result.evidence.ref, replay.evidence.ref)
self.assertEqual("allow", result.freshness.gate_decision)
self.assertFalse(changed.fresh)
self.assertEqual("review", changed.gate_decision)
self.assertIn("subject_changed", changed.reasons)
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(),
)
expired = assess_screening_freshness(
self.session,
principal(),
evidence_ref=screening_evidence_ref(item.id),
expected_list_snapshot_id=self.list_snapshot.id,
failure_policy="review",
now=disposition.expires_at + timedelta(seconds=1),
)
self.assertIn("disposition_expired", expired.reasons)
self.assertEqual("review", expired.gate_decision)
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
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/risk-compliance-webui",
"version": "0.1.18",
"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.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+435
View File
@@ -0,0 +1,435 @@
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 type AssuranceNodeKind =
| "obligation"
| "governed_object"
| "risk"
| "control"
| "evidence"
| "finding"
| "corrective_measure"
| "effectiveness_review";
export type AssuranceNode = {
id: string;
stable_id: string;
kind: AssuranceNodeKind;
revision: number;
previous_revision_id?: string | null;
label: string;
description?: string | null;
state: string;
owner_ref: string;
scope_ref?: string | null;
governed_object_ref?: string | null;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
provenance: Record<string, unknown>;
legal_basis_refs: string[];
policy_refs: string[];
evidence_refs: string[];
classification: string;
created_by?: string | null;
created_at: string;
updated_at: string;
};
export type AssuranceEdge = {
id: string;
stable_id: string;
revision: number;
previous_revision_id?: string | null;
source_node_ref: string;
target_node_ref: string;
relation: string;
state: "active" | "suspended" | "retired";
owner_ref: string;
scope_ref?: string | null;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
provenance: Record<string, unknown>;
legal_basis_refs: string[];
policy_refs: string[];
evidence_refs: string[];
created_by?: string | null;
created_at: string;
updated_at: string;
};
export type AssuranceSummary = {
node_count: number;
edge_count: number;
by_kind: Record<string, number>;
by_state: Record<string, number>;
};
export type AssuranceNodeWrite = Omit<
AssuranceNode,
| "id"
| "revision"
| "previous_revision_id"
| "recorded_at"
| "superseded_at"
| "created_by"
| "created_at"
| "updated_at"
>;
export type AssuranceEdgeWrite = Omit<
AssuranceEdge,
| "id"
| "revision"
| "previous_revision_id"
| "recorded_at"
| "superseded_at"
| "created_by"
| "created_at"
| "updated_at"
>;
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)
}
);
}
export async function listAssuranceNodes(
settings: ApiSettings,
filters: {
kind?: string;
state?: string;
query?: string;
governedObjectRef?: string;
} = {}
) {
return apiFetch<{ nodes: AssuranceNode[] }>(
settings,
apiPath("/api/v1/risk-compliance/assurance/nodes", {
kind: filters.kind,
state: filters.state,
query: filters.query,
governed_object_ref: filters.governedObjectRef,
limit: 500
})
);
}
export async function getAssuranceSummary(settings: ApiSettings) {
return apiFetch<AssuranceSummary>(
settings,
"/api/v1/risk-compliance/assurance/summary"
);
}
export async function getAssuranceGraph(
settings: ApiSettings,
rootRef: string
) {
return apiFetch<{
root_ref: string;
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
}>(
settings,
apiPath("/api/v1/risk-compliance/assurance/graph", {
root_ref: rootRef,
max_depth: 8,
limit: 500
})
);
}
export async function saveAssuranceNode(
settings: ApiSettings,
value: AssuranceNodeWrite,
expectedRevision?: number
) {
return apiFetch<AssuranceNode>(
settings,
expectedRevision
? "/api/v1/risk-compliance/assurance/nodes/revise"
: "/api/v1/risk-compliance/assurance/nodes",
{
method: "POST",
body: JSON.stringify({
...value,
...(expectedRevision
? { expected_revision: expectedRevision }
: {})
})
}
);
}
export async function saveAssuranceEdge(
settings: ApiSettings,
value: AssuranceEdgeWrite,
expectedRevision?: number
) {
return apiFetch<AssuranceEdge>(
settings,
expectedRevision
? "/api/v1/risk-compliance/assurance/edges/revise"
: "/api/v1/risk-compliance/assurance/edges",
{
method: "POST",
body: JSON.stringify({
...value,
...(expectedRevision
? { expected_revision: expectedRevision }
: {})
})
}
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const RISK_COMPLIANCE_DOCUMENTATION = {
topicId: "risk_compliance.module-boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const RISK_COMPLIANCE_ADMIN_DOCUMENTATION = {
topicId: "risk_compliance.module-boundary",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const RISK_COMPLIANCE_I18N = {
loading: "i18n:govoplan-risk-compliance.reason.loading",
busy: "i18n:govoplan-risk-compliance.reason.busy",
adminRequired: "i18n:govoplan-risk-compliance.reason.admin_required",
screenRequired: "i18n:govoplan-risk-compliance.reason.screen_required",
reviewRequired: "i18n:govoplan-risk-compliance.reason.review_required",
assuranceReadRequired: "i18n:govoplan-risk-compliance.reason.assurance_read_required",
assuranceWriteRequired: "i18n:govoplan-risk-compliance.reason.assurance_write_required",
connectorUnavailable: "i18n:govoplan-risk-compliance.reason.connector_unavailable",
snapshotRequired: "i18n:govoplan-risk-compliance.reason.snapshot_required",
subjectRequired: "i18n:govoplan-risk-compliance.reason.subject_required",
candidateRequired: "i18n:govoplan-risk-compliance.reason.candidate_required",
dispositionReasonRequired: "i18n:govoplan-risk-compliance.reason.disposition_reason_required",
exceptionExpiryRequired: "i18n:govoplan-risk-compliance.reason.exception_expiry_required",
assuranceObjectRequired: "i18n:govoplan-risk-compliance.reason.assurance_object_required",
assuranceFieldsRequired: "i18n:govoplan-risk-compliance.reason.assurance_fields_required",
relationRequired: "i18n:govoplan-risk-compliance.reason.relation_required",
systemManagedObject: "i18n:govoplan-risk-compliance.reason.system_managed_object"
} as const;
export const RISK_COMPLIANCE_BLOCKER_LABELS = {
requiredAction: "i18n:govoplan-risk-compliance.blocker.required_action",
actor: "i18n:govoplan-risk-compliance.blocker.actor",
target: "i18n:govoplan-risk-compliance.blocker.target"
} as const;
+106
View File
@@ -0,0 +1,106 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-risk-compliance.surface.sources": "Sanctions source snapshots",
"i18n:govoplan-risk-compliance.surface.screening": "Sanctions screening",
"i18n:govoplan-risk-compliance.surface.review": "Sanctions review queue",
"i18n:govoplan-risk-compliance.surface.assurance": "Assurance graph",
"i18n:govoplan-risk-compliance.surface.import_snapshot": "Import sanctions source snapshot",
"i18n:govoplan-risk-compliance.surface.run_screening": "Run sanctions screening",
"i18n:govoplan-risk-compliance.surface.disposition": "Record screening disposition",
"i18n:govoplan-risk-compliance.surface.assurance_editor": "Assurance object editor",
"i18n:govoplan-risk-compliance.surface.connect_assurance": "Connect assurance objects",
"i18n:govoplan-risk-compliance.reason.loading": "Risk Compliance data is loading.",
"i18n:govoplan-risk-compliance.reason.busy": "A Risk Compliance operation is already running.",
"i18n:govoplan-risk-compliance.reason.admin_required": "Sanctions-administration permission is required.",
"i18n:govoplan-risk-compliance.reason.screen_required": "Sanctions-screening permission is required.",
"i18n:govoplan-risk-compliance.reason.review_required": "Sanctions-review permission is required.",
"i18n:govoplan-risk-compliance.reason.assurance_read_required": "Assurance-read permission is required.",
"i18n:govoplan-risk-compliance.reason.assurance_write_required": "Assurance-write permission is required.",
"i18n:govoplan-risk-compliance.reason.connector_unavailable": "No Connectors sanctions-source provider is enabled.",
"i18n:govoplan-risk-compliance.reason.snapshot_required": "Import and select an active list snapshot first.",
"i18n:govoplan-risk-compliance.reason.subject_required": "Enter a name or identifier to screen.",
"i18n:govoplan-risk-compliance.reason.candidate_required": "Select a candidate from the review queue first.",
"i18n:govoplan-risk-compliance.reason.disposition_reason_required": "Record a reason of at least three characters.",
"i18n:govoplan-risk-compliance.reason.exception_expiry_required": "A reusable exception requires an expiry date.",
"i18n:govoplan-risk-compliance.reason.assurance_object_required": "Select an assurance object first.",
"i18n:govoplan-risk-compliance.reason.assurance_fields_required": "Stable ID, name, owner, state, and effective-from time are required.",
"i18n:govoplan-risk-compliance.reason.relation_required": "Select a valid relationship and target object.",
"i18n:govoplan-risk-compliance.reason.system_managed_object": "Sanctions-derived assurance objects are system managed.",
"i18n:govoplan-risk-compliance.import_snapshot_title": "Import immutable snapshot?",
"i18n:govoplan-risk-compliance.import_snapshot_message": "Import {value0} source version {value1} as immutable sanctions evidence? A matching existing snapshot will be reused.",
"i18n:govoplan-risk-compliance.import_snapshot": "Import snapshot",
"i18n:govoplan-risk-compliance.connector_unavailable_summary": "Sanctions source acquisition is unavailable",
"i18n:govoplan-risk-compliance.connector_unavailable_action": "Enable a compatible Connectors sanctions-source provider, or use an already imported snapshot.",
"i18n:govoplan-risk-compliance.connector_unavailable_actor": "A module administrator",
"i18n:govoplan-risk-compliance.connector_unavailable_target": "Modules and Connectors administration",
"i18n:govoplan-risk-compliance.snapshot_required_summary": "No sanctions snapshot is available",
"i18n:govoplan-risk-compliance.snapshot_required_action": "Import an immutable sanctions-list snapshot before running a screening.",
"i18n:govoplan-risk-compliance.snapshot_required_actor": "A sanctions administrator",
"i18n:govoplan-risk-compliance.snapshot_required_target": "Risk Compliance source snapshots",
"i18n:govoplan-risk-compliance.append_only_summary": "This decision becomes durable evidence",
"i18n:govoplan-risk-compliance.append_only_details": "A disposition or reusable exception is appended to the screening record and is not edited in place.",
"i18n:govoplan-risk-compliance.append_only_action": "Verify the decision, reason, and any exception expiry before recording it.",
"i18n:govoplan-risk-compliance.append_only_actor": "The assigned compliance reviewer",
"i18n:govoplan-risk-compliance.append_only_target": "This screening candidate",
"i18n:govoplan-risk-compliance.assurance_read_only_summary": "Assurance data is read-only for this account",
"i18n:govoplan-risk-compliance.permission_action": "Request the required Risk Compliance permission from an administrator.",
"i18n:govoplan-risk-compliance.permission_actor": "A tenant access administrator",
"i18n:govoplan-risk-compliance.permission_target": "Roles and permissions",
"i18n:govoplan-risk-compliance.blocker.required_action": "Required action",
"i18n:govoplan-risk-compliance.blocker.actor": "Who can fix it",
"i18n:govoplan-risk-compliance.blocker.target": "Where to go"
},
de: {
"i18n:govoplan-risk-compliance.surface.sources": "Sanktionsquellen",
"i18n:govoplan-risk-compliance.surface.screening": "Sanktionsprüfung",
"i18n:govoplan-risk-compliance.surface.review": "Prüfwarteschlange",
"i18n:govoplan-risk-compliance.surface.assurance": "Assurance-Graph",
"i18n:govoplan-risk-compliance.surface.import_snapshot": "Snapshot einer Sanktionsquelle importieren",
"i18n:govoplan-risk-compliance.surface.run_screening": "Sanktionsprüfung ausführen",
"i18n:govoplan-risk-compliance.surface.disposition": "Prüfentscheidung dokumentieren",
"i18n:govoplan-risk-compliance.surface.assurance_editor": "Assurance-Objekt bearbeiten",
"i18n:govoplan-risk-compliance.surface.connect_assurance": "Assurance-Objekte verknüpfen",
"i18n:govoplan-risk-compliance.reason.loading": "Risk-Compliance-Daten werden geladen.",
"i18n:govoplan-risk-compliance.reason.busy": "Eine Risk-Compliance-Aktion wird bereits ausgeführt.",
"i18n:govoplan-risk-compliance.reason.admin_required": "Die Berechtigung zur Verwaltung von Sanktionsprüfungen ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.screen_required": "Die Berechtigung zur Sanktionsprüfung ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.review_required": "Die Berechtigung zur Prüfung von Sanktionskandidaten ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.assurance_read_required": "Die Berechtigung zum Lesen von Assurance-Daten ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.assurance_write_required": "Die Berechtigung zum Bearbeiten von Assurance-Daten ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.connector_unavailable": "Es ist kein Connectors-Anbieter für Sanktionsquellen aktiviert.",
"i18n:govoplan-risk-compliance.reason.snapshot_required": "Importieren und wählen Sie zuerst einen aktiven Listensnapshot.",
"i18n:govoplan-risk-compliance.reason.subject_required": "Geben Sie einen Namen oder eine Kennung zur Prüfung ein.",
"i18n:govoplan-risk-compliance.reason.candidate_required": "Wählen Sie zuerst einen Kandidaten aus der Prüfwarteschlange.",
"i18n:govoplan-risk-compliance.reason.disposition_reason_required": "Dokumentieren Sie eine Begründung mit mindestens drei Zeichen.",
"i18n:govoplan-risk-compliance.reason.exception_expiry_required": "Eine wiederverwendbare Ausnahme benötigt ein Ablaufdatum.",
"i18n:govoplan-risk-compliance.reason.assurance_object_required": "Wählen Sie zuerst ein Assurance-Objekt.",
"i18n:govoplan-risk-compliance.reason.assurance_fields_required": "Stabile ID, Name, zuständige Stelle, Status und Gültigkeitsbeginn sind erforderlich.",
"i18n:govoplan-risk-compliance.reason.relation_required": "Wählen Sie eine gültige Beziehung und ein Zielobjekt.",
"i18n:govoplan-risk-compliance.reason.system_managed_object": "Aus Sanktionsprüfungen abgeleitete Assurance-Objekte werden vom System verwaltet.",
"i18n:govoplan-risk-compliance.import_snapshot_title": "Unveränderlichen Snapshot importieren?",
"i18n:govoplan-risk-compliance.import_snapshot_message": "Soll {value0}, Quellversion {value1}, als unveränderlicher Sanktionsnachweis importiert werden? Ein vorhandener identischer Snapshot wird wiederverwendet.",
"i18n:govoplan-risk-compliance.import_snapshot": "Snapshot importieren",
"i18n:govoplan-risk-compliance.connector_unavailable_summary": "Sanktionsquellen können nicht abgerufen werden",
"i18n:govoplan-risk-compliance.connector_unavailable_action": "Aktivieren Sie einen kompatiblen Connectors-Anbieter für Sanktionsquellen oder verwenden Sie einen bereits importierten Snapshot.",
"i18n:govoplan-risk-compliance.connector_unavailable_actor": "Eine Moduladministration",
"i18n:govoplan-risk-compliance.connector_unavailable_target": "Modul- und Connectors-Administration",
"i18n:govoplan-risk-compliance.snapshot_required_summary": "Es ist kein Sanktionssnapshot verfügbar",
"i18n:govoplan-risk-compliance.snapshot_required_action": "Importieren Sie vor der Prüfung einen unveränderlichen Snapshot einer Sanktionsliste.",
"i18n:govoplan-risk-compliance.snapshot_required_actor": "Eine Sanktionsadministration",
"i18n:govoplan-risk-compliance.snapshot_required_target": "Sanktionsquellen in Risk Compliance",
"i18n:govoplan-risk-compliance.append_only_summary": "Diese Entscheidung wird zum dauerhaften Nachweis",
"i18n:govoplan-risk-compliance.append_only_details": "Eine Prüfentscheidung oder wiederverwendbare Ausnahme wird dem Prüfdatensatz angefügt und nicht direkt geändert.",
"i18n:govoplan-risk-compliance.append_only_action": "Prüfen Sie Entscheidung, Begründung und gegebenenfalls das Ablaufdatum, bevor Sie den Eintrag anlegen.",
"i18n:govoplan-risk-compliance.append_only_actor": "Die zuständige Compliance-Prüfung",
"i18n:govoplan-risk-compliance.append_only_target": "Dieser Prüfungskandidat",
"i18n:govoplan-risk-compliance.assurance_read_only_summary": "Assurance-Daten sind für dieses Konto schreibgeschützt",
"i18n:govoplan-risk-compliance.permission_action": "Fordern Sie die erforderliche Risk-Compliance-Berechtigung bei einer Administration an.",
"i18n:govoplan-risk-compliance.permission_actor": "Eine Zugriffsadministration des Mandanten",
"i18n:govoplan-risk-compliance.permission_target": "Rollen und Berechtigungen",
"i18n:govoplan-risk-compliance.blocker.required_action": "Erforderliche Aktion",
"i18n:govoplan-risk-compliance.blocker.actor": "Zuständige Stelle",
"i18n:govoplan-risk-compliance.blocker.target": "Ziel"
}
};
+2
View File
@@ -0,0 +1,2 @@
export { default, riskComplianceModule } from "./module";
export * from "./api/riskCompliance";
+124
View File
@@ -0,0 +1,124 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
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"
],
translations: generatedTranslations,
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: "i18n:govoplan-risk-compliance.surface.sources",
parentId: "risk_compliance.workspace",
order: 20
},
{
id: "risk_compliance.sanctions.screening",
moduleId: "risk_compliance",
kind: "section",
label: "i18n:govoplan-risk-compliance.surface.screening",
parentId: "risk_compliance.workspace",
order: 30
},
{
id: "risk_compliance.sanctions.review",
moduleId: "risk_compliance",
kind: "section",
label: "i18n:govoplan-risk-compliance.surface.review",
parentId: "risk_compliance.workspace",
order: 40
},
{
id: "risk_compliance.assurance.graph",
moduleId: "risk_compliance",
kind: "section",
label: "i18n:govoplan-risk-compliance.surface.assurance",
parentId: "risk_compliance.workspace",
order: 50
},
{
id: "risk_compliance.action.import-snapshot",
moduleId: "risk_compliance",
kind: "action",
label: "i18n:govoplan-risk-compliance.surface.import_snapshot",
parentId: "risk_compliance.sanctions.sources",
order: 60
},
{
id: "risk_compliance.action.run-screening",
moduleId: "risk_compliance",
kind: "action",
label: "i18n:govoplan-risk-compliance.surface.run_screening",
parentId: "risk_compliance.sanctions.screening",
order: 70
},
{
id: "risk_compliance.review.disposition",
moduleId: "risk_compliance",
kind: "dialog",
label: "i18n:govoplan-risk-compliance.surface.disposition",
parentId: "risk_compliance.sanctions.review",
order: 80
},
{
id: "risk_compliance.assurance.editor",
moduleId: "risk_compliance",
kind: "dialog",
label: "i18n:govoplan-risk-compliance.surface.assurance_editor",
parentId: "risk_compliance.assurance.graph",
order: 90
},
{
id: "risk_compliance.action.connect-assurance",
moduleId: "risk_compliance",
kind: "action",
label: "i18n:govoplan-risk-compliance.surface.connect_assurance",
parentId: "risk_compliance.assurance.graph",
order: 100
}
]
};
export default riskComplianceModule;
+538
View File
@@ -0,0 +1,538 @@
.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,
.risk-assurance-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-assurance-layout {
grid-template-rows: auto minmax(0, 1fr);
}
.risk-assurance-summary {
display: grid;
min-width: 0;
gap: 10px;
}
.risk-assurance-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.risk-assurance-columns {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: minmax(300px, 0.75fr) minmax(480px, 1.55fr);
gap: 12px;
}
.risk-assurance-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.45fr);
gap: 8px;
border-bottom: var(--border-line);
padding: 8px 10px;
}
.risk-assurance-filter input,
.risk-assurance-filter select,
.risk-assurance-form input,
.risk-assurance-form select,
.risk-assurance-form textarea {
width: 100%;
box-sizing: border-box;
}
.risk-assurance-detail-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: auto;
}
.risk-assurance-detail-body > p {
margin: 0;
border-bottom: var(--border-line);
color: var(--text);
padding: 12px;
line-height: 1.5;
}
.risk-assurance-properties {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
border-bottom: var(--border-line);
}
.risk-assurance-properties > div {
min-width: 0;
border-right: var(--border-line);
border-bottom: var(--border-line);
padding: 10px 12px;
}
.risk-assurance-properties > div:nth-child(2n) {
border-right: 0;
}
.risk-assurance-properties dt {
margin-bottom: 4px;
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.risk-assurance-properties dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-size: 12px;
}
.risk-assurance-links-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
border-bottom: var(--border-line);
padding: 8px 12px;
}
.risk-assurance-links-header > div {
display: grid;
gap: 2px;
flex: 1;
}
.risk-assurance-links-header span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links {
min-height: 0;
overflow: auto;
}
.risk-assurance-links > button {
display: grid;
width: 100%;
grid-template-columns: minmax(110px, 0.45fr) minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
padding: 9px 12px;
text-align: left;
font: inherit;
cursor: pointer;
}
.risk-assurance-links > button:hover {
background: var(--sidebar-hover-bg);
}
.risk-assurance-links > button span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links > button strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.risk-assurance-truncated {
border-top: var(--border-line);
color: var(--warning);
padding: 9px 12px;
font-size: 11px;
}
.risk-assurance-form {
display: grid;
gap: 12px;
}
.risk-assurance-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.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-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.risk-list,
.risk-result-body,
.risk-evidence-body {
min-height: 0;
flex: 1;
overflow: auto;
}
.risk-list-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 {
display: flex;
min-height: 58px;
align-items: center;
gap: 10px;
}
.risk-list > .selection-list {
gap: 2px;
padding: 4px;
}
.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,
.risk-assurance-layout,
.risk-assurance-columns {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-assurance-form-grid,
.risk-assurance-properties {
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-properties > div {
border-right: 0;
}
.risk-panel {
min-height: 340px;
}
.risk-comparison {
grid-template-columns: minmax(0, 1fr);
}
.risk-evidence-column {
border-right: 0;
border-bottom: var(--border-line);
}
}