Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e5caa447 | ||
|
|
37d7120023 | ||
|
|
bd88b623a6 | ||
|
|
dabc568429 | ||
|
|
504255a0bd | ||
|
|
05e1e246ca | ||
|
|
8a50529f71 | ||
|
|
a633100a97 | ||
|
|
08ec16f459 | ||
|
|
fe9c44ff44 | ||
|
|
0ab9f76bc9 | ||
|
|
075aa4102c | ||
|
|
5010f0833d | ||
|
|
24d80a6d7d | ||
|
|
a23b53dc9e | ||
|
|
bbf7288e14 | ||
|
|
da1baede36 | ||
|
|
11404ac42f | ||
|
|
05c3a2257a | ||
|
|
587991dcfb |
@@ -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
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -1,8 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/risk-compliance",
|
"name": "@govoplan/risk-compliance-webui",
|
||||||
"version": "0.1.7",
|
"version": "0.1.19",
|
||||||
"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
@@ -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.19"
|
||||||
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.37",
|
||||||
|
"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
@@ -0,0 +1,695 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningDisposition,
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningRun,
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY = dsar_capability_name("risk_compliance")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_MAX_SUBJECT_ITEMS = 100
|
||||||
|
_MAX_SUBJECT_BYTES = 256 * 1024
|
||||||
|
_CONFLICT = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
account_id: str | None
|
||||||
|
membership_id: str | None
|
||||||
|
subject_ref: str | None
|
||||||
|
screening_id: str | None
|
||||||
|
assurance_node_id: str | None
|
||||||
|
assurance_edge_id: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
value for value in (self.account_id, self.membership_id) if value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def narrowed(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.screening_id or self.assurance_node_id or self.assurance_edge_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskComplianceDsarProvider:
|
||||||
|
provider_id = "risk_compliance"
|
||||||
|
module_id = "risk_compliance"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
if selectors.subject_ref and (not selectors.narrowed or selectors.screening_id):
|
||||||
|
query = (
|
||||||
|
db.query(RiskScreeningRun, RiskScreeningSubjectSnapshot)
|
||||||
|
.join(
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
RiskScreeningSubjectSnapshot.id
|
||||||
|
== RiskScreeningRun.subject_snapshot_id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
RiskScreeningRun.tenant_id == tenant_id,
|
||||||
|
RiskScreeningSubjectSnapshot.tenant_id == tenant_id,
|
||||||
|
RiskScreeningSubjectSnapshot.subject_ref == selectors.subject_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if selectors.screening_id:
|
||||||
|
query = query.filter(RiskScreeningRun.id == selectors.screening_id)
|
||||||
|
records.extend(
|
||||||
|
_subject_screening_record(run, snapshot)
|
||||||
|
for run, snapshot in _limited(
|
||||||
|
query,
|
||||||
|
RiskScreeningRun.started_at,
|
||||||
|
RiskScreeningRun.id,
|
||||||
|
label="subject screening",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
actor_ids = selectors.actor_ids
|
||||||
|
if actor_ids and (not selectors.narrowed or selectors.screening_id):
|
||||||
|
run_query = (
|
||||||
|
db.query(RiskScreeningRun, RiskScreeningSubjectSnapshot)
|
||||||
|
.join(
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
RiskScreeningSubjectSnapshot.id
|
||||||
|
== RiskScreeningRun.subject_snapshot_id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
RiskScreeningRun.tenant_id == tenant_id,
|
||||||
|
RiskScreeningSubjectSnapshot.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
RiskScreeningRun.created_by.in_(actor_ids),
|
||||||
|
RiskScreeningSubjectSnapshot.submitted_by.in_(actor_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if selectors.screening_id:
|
||||||
|
run_query = run_query.filter(
|
||||||
|
RiskScreeningRun.id == selectors.screening_id
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_screening_actor_record(run, snapshot, actor_ids)
|
||||||
|
for run, snapshot in _limited(
|
||||||
|
run_query,
|
||||||
|
RiskScreeningRun.started_at,
|
||||||
|
RiskScreeningRun.id,
|
||||||
|
label="screening actor attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
disposition_query = db.query(RiskScreeningDisposition).filter(
|
||||||
|
RiskScreeningDisposition.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
RiskScreeningDisposition.actor_account_id.in_(actor_ids),
|
||||||
|
RiskScreeningDisposition.actor_membership_id.in_(actor_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if selectors.screening_id:
|
||||||
|
disposition_query = (
|
||||||
|
db.query(RiskScreeningDisposition)
|
||||||
|
.join(
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningCandidate.id
|
||||||
|
== RiskScreeningDisposition.candidate_id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
RiskScreeningDisposition.tenant_id == tenant_id,
|
||||||
|
RiskScreeningCandidate.run_id == selectors.screening_id,
|
||||||
|
or_(
|
||||||
|
RiskScreeningDisposition.actor_account_id.in_(actor_ids),
|
||||||
|
RiskScreeningDisposition.actor_membership_id.in_(actor_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_disposition_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
disposition_query,
|
||||||
|
RiskScreeningDisposition.created_at,
|
||||||
|
RiskScreeningDisposition.id,
|
||||||
|
label="disposition attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if actor_ids and not selectors.narrowed:
|
||||||
|
records.extend(self._unnarrowed_actor_records(db, tenant_id, actor_ids))
|
||||||
|
|
||||||
|
if actor_ids and selectors.assurance_node_id:
|
||||||
|
query = db.query(RiskAssuranceNode).filter(
|
||||||
|
RiskAssuranceNode.tenant_id == tenant_id,
|
||||||
|
RiskAssuranceNode.id == selectors.assurance_node_id,
|
||||||
|
RiskAssuranceNode.created_by.in_(actor_ids),
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_assurance_node_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
query,
|
||||||
|
RiskAssuranceNode.recorded_at,
|
||||||
|
RiskAssuranceNode.id,
|
||||||
|
label="assurance-node attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if actor_ids and selectors.assurance_edge_id:
|
||||||
|
query = db.query(RiskAssuranceEdge).filter(
|
||||||
|
RiskAssuranceEdge.tenant_id == tenant_id,
|
||||||
|
RiskAssuranceEdge.id == selectors.assurance_edge_id,
|
||||||
|
RiskAssuranceEdge.created_by.in_(actor_ids),
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_assurance_edge_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
query,
|
||||||
|
RiskAssuranceEdge.recorded_at,
|
||||||
|
RiskAssuranceEdge.id,
|
||||||
|
label="assurance-edge attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(records) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Risk Compliance DSAR result limit exceeded; narrow selectors."
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unnarrowed_actor_records(
|
||||||
|
db: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
) -> list[DsarRecordRef]:
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
imports = db.query(RiskSanctionsListSnapshot).filter(
|
||||||
|
RiskSanctionsListSnapshot.tenant_id == tenant_id,
|
||||||
|
RiskSanctionsListSnapshot.imported_by.in_(actor_ids),
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_snapshot_import_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
imports,
|
||||||
|
RiskSanctionsListSnapshot.imported_at,
|
||||||
|
RiskSanctionsListSnapshot.id,
|
||||||
|
label="snapshot-import attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exceptions = db.query(RiskScreeningException).filter(
|
||||||
|
RiskScreeningException.tenant_id == tenant_id,
|
||||||
|
RiskScreeningException.created_by.in_(actor_ids),
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_exception_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
exceptions,
|
||||||
|
RiskScreeningException.created_at,
|
||||||
|
RiskScreeningException.id,
|
||||||
|
label="exception attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes = db.query(RiskAssuranceNode).filter(
|
||||||
|
RiskAssuranceNode.tenant_id == tenant_id,
|
||||||
|
RiskAssuranceNode.created_by.in_(actor_ids),
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_assurance_node_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
nodes,
|
||||||
|
RiskAssuranceNode.recorded_at,
|
||||||
|
RiskAssuranceNode.id,
|
||||||
|
label="assurance-node attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
edges = db.query(RiskAssuranceEdge).filter(
|
||||||
|
RiskAssuranceEdge.tenant_id == tenant_id,
|
||||||
|
RiskAssuranceEdge.created_by.in_(actor_ids),
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_assurance_edge_actor_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
edges,
|
||||||
|
RiskAssuranceEdge.recorded_at,
|
||||||
|
RiskAssuranceEdge.id,
|
||||||
|
label="assurance-edge attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Risk Compliance DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"risk_compliance:retain:{record.resource_type}:"
|
||||||
|
f"{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=(
|
||||||
|
record.retention_reason
|
||||||
|
or "Risk and compliance evidence remains immutable."
|
||||||
|
),
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Risk Compliance DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable or action.kind != "retain":
|
||||||
|
raise ValueError("Risk Compliance DSAR publishes retain actions only.")
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Risk and compliance evidence remains unchanged under its "
|
||||||
|
"legal, audit, and accountability obligations."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
values = {
|
||||||
|
"account_id": _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("risk_compliance.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
),
|
||||||
|
"membership_id": _coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
references.get("risk_compliance.membership"),
|
||||||
|
references.get("tenancy.membership"),
|
||||||
|
),
|
||||||
|
"subject_ref": _coalesce(
|
||||||
|
references.get("risk_compliance.subject"),
|
||||||
|
references.get("risk_compliance.subject_ref"),
|
||||||
|
),
|
||||||
|
"screening_id": _coalesce(
|
||||||
|
references.get("risk_compliance.screening"),
|
||||||
|
references.get("risk_compliance.screening_id"),
|
||||||
|
),
|
||||||
|
"assurance_node_id": _coalesce(
|
||||||
|
references.get("risk_compliance.assurance_node"),
|
||||||
|
references.get("risk_compliance.assurance_node_id"),
|
||||||
|
),
|
||||||
|
"assurance_edge_id": _coalesce(
|
||||||
|
references.get("risk_compliance.assurance_edge"),
|
||||||
|
references.get("risk_compliance.assurance_edge_id"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(value is _CONFLICT for value in values.values()):
|
||||||
|
return None
|
||||||
|
selectors = _SubjectSelectors(
|
||||||
|
account_id=_optional_string(values["account_id"]),
|
||||||
|
membership_id=_optional_string(values["membership_id"]),
|
||||||
|
subject_ref=_optional_string(values["subject_ref"]),
|
||||||
|
screening_id=_optional_string(values["screening_id"]),
|
||||||
|
assurance_node_id=_optional_string(values["assurance_node_id"]),
|
||||||
|
assurance_edge_id=_optional_string(values["assurance_edge_id"]),
|
||||||
|
)
|
||||||
|
if not selectors.actor_ids and not selectors.subject_ref:
|
||||||
|
return None
|
||||||
|
return selectors
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_screening_record(
|
||||||
|
run: RiskScreeningRun, snapshot: RiskScreeningSubjectSnapshot
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
data = {
|
||||||
|
"screening_id": run.id,
|
||||||
|
"subject_snapshot_id": snapshot.id,
|
||||||
|
"subject_ref": snapshot.subject_ref,
|
||||||
|
"subject_type": snapshot.subject_type,
|
||||||
|
"primary_name": (snapshot.primary_name or "")[:1_000] or None,
|
||||||
|
"aliases": _string_list(snapshot.aliases, 1_000),
|
||||||
|
"identifiers": _mapping_list(
|
||||||
|
snapshot.identifiers,
|
||||||
|
allowed=("type", "value"),
|
||||||
|
limits={"type": 100, "value": 1_000},
|
||||||
|
),
|
||||||
|
"dates": _string_list(snapshot.dates, 100),
|
||||||
|
"addresses": _mapping_list(
|
||||||
|
snapshot.addresses,
|
||||||
|
allowed=("street", "city", "region", "postal_code", "country"),
|
||||||
|
limits={
|
||||||
|
"street": 1_000,
|
||||||
|
"city": 500,
|
||||||
|
"region": 500,
|
||||||
|
"postal_code": 100,
|
||||||
|
"country": 255,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"status": run.status,
|
||||||
|
"outcome": run.outcome,
|
||||||
|
"candidate_count": run.candidate_count,
|
||||||
|
"started_at": _iso(run.started_at),
|
||||||
|
"completed_at": _iso(run.completed_at),
|
||||||
|
}
|
||||||
|
_bounded_json(data)
|
||||||
|
return _record(
|
||||||
|
resource_type="screening_subject_submission",
|
||||||
|
resource_id=run.id,
|
||||||
|
category="sanctions_screening_subject_data",
|
||||||
|
title="Sanctions screening subject submission",
|
||||||
|
data=data,
|
||||||
|
observed_at=run.completed_at or run.started_at,
|
||||||
|
retention_reason=(
|
||||||
|
"Version-pinned screening inputs and outcomes are retained as legal and "
|
||||||
|
"compliance evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _screening_actor_record(
|
||||||
|
run: RiskScreeningRun,
|
||||||
|
snapshot: RiskScreeningSubjectSnapshot,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
activities = []
|
||||||
|
if run.created_by in actor_ids:
|
||||||
|
activities.append("created_screening")
|
||||||
|
if snapshot.submitted_by in actor_ids:
|
||||||
|
activities.append("submitted_screening_subject")
|
||||||
|
return _record(
|
||||||
|
resource_type="screening_actor_attribution",
|
||||||
|
resource_id=run.id,
|
||||||
|
category="risk_compliance_actor_attribution",
|
||||||
|
title="Screening actor attribution",
|
||||||
|
data={
|
||||||
|
"screening_id": run.id,
|
||||||
|
"status": run.status,
|
||||||
|
"outcome": run.outcome,
|
||||||
|
"candidate_count": run.candidate_count,
|
||||||
|
"activities": activities,
|
||||||
|
"started_at": _iso(run.started_at),
|
||||||
|
"completed_at": _iso(run.completed_at),
|
||||||
|
},
|
||||||
|
observed_at=run.completed_at or run.started_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_import_actor_record(row: RiskSanctionsListSnapshot) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
resource_type="snapshot_import_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="risk_compliance_actor_attribution",
|
||||||
|
title="Sanctions snapshot import attribution",
|
||||||
|
data={
|
||||||
|
"snapshot_id": row.id,
|
||||||
|
"provider_id": row.provider_id,
|
||||||
|
"source_id": row.source_id,
|
||||||
|
"source_version": row.source_version,
|
||||||
|
"status": row.status,
|
||||||
|
"entry_count": row.entry_count,
|
||||||
|
"activity": "imported_sanctions_snapshot",
|
||||||
|
"imported_at": _iso(row.imported_at),
|
||||||
|
},
|
||||||
|
observed_at=row.imported_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _disposition_actor_record(row: RiskScreeningDisposition) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
resource_type="disposition_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="risk_compliance_actor_attribution",
|
||||||
|
title="Screening disposition actor attribution",
|
||||||
|
data={
|
||||||
|
"disposition_id": row.id,
|
||||||
|
"candidate_id": row.candidate_id,
|
||||||
|
"decision": row.decision,
|
||||||
|
"scope": row.scope,
|
||||||
|
"separation_status": row.separation_status,
|
||||||
|
"expires_at": _iso(row.expires_at),
|
||||||
|
"review_at": _iso(row.review_at),
|
||||||
|
"activity": "recorded_screening_disposition",
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exception_actor_record(row: RiskScreeningException) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
resource_type="exception_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="risk_compliance_actor_attribution",
|
||||||
|
title="Screening exception actor attribution",
|
||||||
|
data={
|
||||||
|
"exception_id": row.id,
|
||||||
|
"scope": row.scope,
|
||||||
|
"status": row.status,
|
||||||
|
"starts_at": _iso(row.starts_at),
|
||||||
|
"expires_at": _iso(row.expires_at),
|
||||||
|
"review_at": _iso(row.review_at),
|
||||||
|
"activity": "created_screening_exception",
|
||||||
|
},
|
||||||
|
observed_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assurance_node_actor_record(row: RiskAssuranceNode) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
resource_type="assurance_node_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="risk_compliance_actor_attribution",
|
||||||
|
title="Assurance-object actor attribution",
|
||||||
|
data={
|
||||||
|
"assurance_node_id": row.id,
|
||||||
|
"stable_id": row.stable_id,
|
||||||
|
"kind": row.kind,
|
||||||
|
"revision": row.revision,
|
||||||
|
"state": row.state,
|
||||||
|
"valid_from": _iso(row.valid_from),
|
||||||
|
"valid_to": _iso(row.valid_to),
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
"activity": "created_assurance_object_revision",
|
||||||
|
},
|
||||||
|
observed_at=row.recorded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assurance_edge_actor_record(row: RiskAssuranceEdge) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
resource_type="assurance_edge_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="risk_compliance_actor_attribution",
|
||||||
|
title="Assurance-relation actor attribution",
|
||||||
|
data={
|
||||||
|
"assurance_edge_id": row.id,
|
||||||
|
"stable_id": row.stable_id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"relation": row.relation,
|
||||||
|
"state": row.state,
|
||||||
|
"valid_from": _iso(row.valid_from),
|
||||||
|
"valid_to": _iso(row.valid_to),
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
"activity": "created_assurance_relation_revision",
|
||||||
|
},
|
||||||
|
observed_at=row.recorded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: Mapping[str, object],
|
||||||
|
observed_at: datetime | None,
|
||||||
|
retention_reason: str | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="risk_compliance",
|
||||||
|
module_id="risk_compliance",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=_aware(observed_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=(
|
||||||
|
retention_reason
|
||||||
|
or "Risk and compliance attribution is retained for legal, audit, and accountability evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object, item_limit: int) -> list[str]:
|
||||||
|
if not isinstance(value, list) or len(value) > _MAX_SUBJECT_ITEMS:
|
||||||
|
raise ValueError("Risk Compliance DSAR subject list exceeds its bound.")
|
||||||
|
return [str(item)[:item_limit] for item in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_list(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
allowed: tuple[str, ...],
|
||||||
|
limits: Mapping[str, int],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(value, list) or len(value) > _MAX_SUBJECT_ITEMS:
|
||||||
|
raise ValueError("Risk Compliance DSAR subject mapping list exceeds its bound.")
|
||||||
|
result: list[dict[str, str]] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
raise ValueError("Risk Compliance DSAR subject mapping is invalid.")
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
key: str(item[key])[: limits[key]]
|
||||||
|
for key in allowed
|
||||||
|
if item.get(key) is not None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_json(value: object) -> None:
|
||||||
|
try:
|
||||||
|
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Risk Compliance DSAR subject data is invalid.") from exc
|
||||||
|
if len(encoded) > _MAX_SUBJECT_BYTES:
|
||||||
|
raise ValueError("Risk Compliance DSAR subject data exceeds its byte bound.")
|
||||||
|
|
||||||
|
|
||||||
|
def _limited(query, first, second, *, label: str):
|
||||||
|
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Risk Compliance DSAR {label} limit exceeded; narrow selectors."
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Risk Compliance DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_RESOURCE_TYPES = {
|
||||||
|
"screening_subject_submission",
|
||||||
|
"screening_actor_attribution",
|
||||||
|
"snapshot_import_actor_attribution",
|
||||||
|
"disposition_actor_attribution",
|
||||||
|
"exception_actor_attribution",
|
||||||
|
"assurance_node_actor_attribution",
|
||||||
|
"assurance_edge_actor_attribution",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "risk_compliance" or record.module_id != "risk_compliance":
|
||||||
|
raise ValueError("Risk Compliance DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||||
|
raise ValueError("Risk Compliance DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "risk_compliance" or action.module_id != "risk_compliance":
|
||||||
|
raise ValueError(
|
||||||
|
"Risk Compliance DSAR cannot execute a foreign provider action."
|
||||||
|
)
|
||||||
|
if not action.action_id.startswith("risk_compliance:retain:"):
|
||||||
|
raise ValueError("Risk Compliance DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["RISK_COMPLIANCE_DSAR_CAPABILITY", "RiskComplianceDsarProvider"]
|
||||||
@@ -1,14 +1,75 @@
|
|||||||
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 (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
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.dsar_provider import (
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY,
|
||||||
|
RiskComplianceDsarProvider,
|
||||||
|
)
|
||||||
|
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.19"
|
||||||
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 +78,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 +182,306 @@ 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 _dsar_provider(_context) -> RiskComplianceDsarProvider:
|
||||||
|
return RiskComplianceDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
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="Screen sanctions and manage assurance evidence",
|
||||||
summary="Risk and compliance workflows for data protection incidents, DPIAs, compliance controls, audit measures, risk registers, and internal-control evidence.",
|
summary=(
|
||||||
|
"Run version-pinned sanctions screening and connect risks, controls, "
|
||||||
|
"evidence, findings, and corrective measures without automating legal conclusions."
|
||||||
|
),
|
||||||
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",
|
||||||
|
),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(any_scopes=(READ_SCOPE, SANCTIONS_READ_SCOPE)),
|
||||||
|
),
|
||||||
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,
|
"kind": "workflow",
|
||||||
"domain_objects": ['risk registers', 'compliance controls', 'DPIA records', 'data protection incident records', 'audit measures', 'internal-control evidence'],
|
"purpose": (
|
||||||
"first_slice": "Define risk register, control, evidence, DPIA, incident, measure, and review-cycle concepts.",
|
"Produce reproducible screening and assurance evidence while keeping legal review explicit and human-accountable."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"The actor can read the relevant assurance or sanctions area; import, screening, review, and editing use dedicated scopes.",
|
||||||
|
"A connector-provided source snapshot is available before sanctions-list import.",
|
||||||
|
"The minimum necessary screening subject data and an exact governed subject reference are available.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Import connector evidence into an immutable normalized sanctions-list snapshot.",
|
||||||
|
"Run screening against one pinned snapshot using only the required subject data.",
|
||||||
|
"Review every fuzzy candidate and record an evidence-backed disposition or time-bounded exception.",
|
||||||
|
"Create or revise assurance objects for obligations, risks, controls, evidence, findings, and corrective measures.",
|
||||||
|
"Connect assurance revisions through typed governed relationships and review effectiveness over time.",
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"source_snapshot": "An immutable normalized list revision with connector provenance and content fingerprint.",
|
||||||
|
"screening_run": "A version-pinned comparison of minimum subject data against one source snapshot.",
|
||||||
|
"candidate": "Potential matching evidence that requires human review and is never a confirmed match by itself.",
|
||||||
|
"disposition": "An append-only legal review outcome with reviewer, reason, evidence, and authority context.",
|
||||||
|
"exception": "A subject-and-entry decision bounded by explicit validity and expiry.",
|
||||||
|
"assurance_revision": "An effective-dated immutable revision of an obligation, risk, control, evidence, finding, measure, or review.",
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Fuzzy matching only creates candidates and never confirms a sanctions match or legal prohibition.",
|
||||||
|
"Risk Compliance does not replace source acquisition, governed domain objects, Policy decisions, Audit evidence, or Records retention.",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Queue and audit summaries use stable references and the minimum necessary subject data.",
|
||||||
|
"Governed domain objects are linked through opaque references rather than copied into the assurance graph.",
|
||||||
|
],
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"Every screening result names the exact list snapshot, subject fingerprint, policy provenance, and run revision.",
|
||||||
|
"Every candidate remains pending until an authorized reviewer appends a disposition or exception.",
|
||||||
|
"Every assurance node and edge is tenant-scoped, effective-dated, revisioned, and linked by governed references.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Sanktionsprüfung und Assurance-Nachweise steuern",
|
||||||
|
"summary": (
|
||||||
|
"Versionsgebundene Sanktionsprüfungen durchführen und Risiken, Kontrollen, "
|
||||||
|
"Nachweise, Feststellungen und Korrekturmaßnahmen verknüpfen, ohne rechtliche Schlüsse zu automatisieren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Connectors können Quellnachweise beschaffen; Risk Compliance führt jedoch unveränderliche "
|
||||||
|
"normalisierte Sanktionslisten, versionsgebundene Prüfungen, Kandidatenbewertungen und rechtliche "
|
||||||
|
"Dispositionen. Unscharfer Abgleich erzeugt nur Kandidaten und bestätigt niemals einen Treffer. "
|
||||||
|
"Die weitergehende Modulrichtung verknüpft Verpflichtungen, gesteuerte Objektreferenzen, Risiken, "
|
||||||
|
"Kontrollen, Nachweise, Feststellungen, Korrekturmaßnahmen und Wirksamkeitsprüfungen, ohne das "
|
||||||
|
"gesteuerte Fachobjekt zu kopieren oder Policy und Audit zu ersetzen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"purpose": (
|
||||||
|
"Reproduzierbare Prüf- und Assurance-Nachweise erzeugen und rechtliche Bewertung ausdrücklich und menschlich verantwortet halten."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"Die handelnde Person darf den jeweiligen Assurance- oder Sanktionsbereich lesen; Import, Prüfung, Bewertung und Bearbeitung verwenden eigene Berechtigungen.",
|
||||||
|
"Vor dem Import einer Sanktionsliste liegt ein von einem Connector bereitgestellter Quellsnapshot vor.",
|
||||||
|
"Die minimal erforderlichen Betroffenendaten und eine exakte gesteuerte Betroffenenreferenz sind verfügbar.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Connector-Nachweise in einen unveränderlichen normalisierten Sanktionslistensnapshot importieren.",
|
||||||
|
"Eine Prüfung mit nur den erforderlichen Betroffenendaten gegen genau einen fixierten Snapshot ausführen.",
|
||||||
|
"Jeden unscharfen Kandidaten prüfen und eine nachweisgestützte Disposition oder befristete Ausnahme aufzeichnen.",
|
||||||
|
"Assurance-Objekte für Verpflichtungen, Risiken, Kontrollen, Nachweise, Feststellungen und Korrekturmaßnahmen anlegen oder revidieren.",
|
||||||
|
"Assurance-Revisionen durch typisierte gesteuerte Beziehungen verbinden und ihre Wirksamkeit im Zeitverlauf prüfen.",
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"source_snapshot": "Eine unveränderliche normalisierte Listenrevision mit Connector-Provenienz und Inhaltsfingerabdruck.",
|
||||||
|
"screening_run": "Ein versionsgebundener Vergleich minimaler Betroffenendaten mit genau einem Quellsnapshot.",
|
||||||
|
"candidate": "Potenzieller Übereinstimmungsnachweis, der menschliche Prüfung erfordert und allein niemals ein bestätigter Treffer ist.",
|
||||||
|
"disposition": "Ein nur anfügbares rechtliches Prüfungsergebnis mit prüfender Person, Begründung, Nachweis und Zuständigkeitskontext.",
|
||||||
|
"exception": "Eine Entscheidung für Betroffenen- und Listeneintrag mit ausdrücklicher Gültigkeit und Ablaufzeit.",
|
||||||
|
"assurance_revision": "Eine zeitlich wirksame unveränderliche Revision von Verpflichtung, Risiko, Kontrolle, Nachweis, Feststellung, Maßnahme oder Prüfung.",
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Unscharfer Abgleich erzeugt nur Kandidaten und bestätigt niemals einen Sanktionstreffer oder ein rechtliches Verbot.",
|
||||||
|
"Risk Compliance ersetzt weder Quellenbeschaffung noch gesteuerte Fachobjekte, Policy-Entscheidungen, Audit-Nachweise oder Records-Aufbewahrung.",
|
||||||
|
],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Warteschlangen- und Auditübersichten verwenden stabile Referenzen und die minimal erforderlichen Betroffenendaten.",
|
||||||
|
"Gesteuerte Fachobjekte werden über opake Referenzen verknüpft und nicht in den Assurance-Graphen kopiert.",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"import_snapshot": "Kopiert Connector-Nachweise in einen unveränderlichen normalisierten Sanktionslistensnapshot.",
|
||||||
|
"run_screening": "Erzeugt unveränderliche versionsgebundene Prüfnachweise aus den minimal erforderlichen Betroffenendaten.",
|
||||||
|
"record_disposition": "Fügt eine nachweisgestützte rechtliche Disposition an, die nicht an Ort und Stelle bearbeitet wird.",
|
||||||
|
"record_exception": "Fügt eine befristete Ausnahme für Betroffenen- und Listeneintrag mit ausdrücklichem Ablauf an.",
|
||||||
|
"revise_assurance_object": "Fügt eine neue zeitlich wirksame Revision an und bewahrt frühere Nachweise.",
|
||||||
|
"connect_assurance_objects": "Fügt eine gesteuerte typisierte Beziehung zwischen Assurance-Objekten an.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"Jedes Prüfergebnis nennt exakten Listensnapshot, Betroffenenfingerabdruck, Richtlinienherkunft und Ausführungsrevision.",
|
||||||
|
"Jeder Kandidat bleibt offen, bis eine befugte prüfende Person eine Disposition oder Ausnahme anfügt.",
|
||||||
|
"Jeder Assurance-Knoten und jede Kante ist mandantenbegrenzt, zeitlich wirksam, revisioniert und durch gesteuerte Referenzen verknüpft.",
|
||||||
|
],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -91,12 +492,287 @@ 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",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=RISK_COMPLIANCE_DSAR_CAPABILITY,
|
||||||
|
version="0.1.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,
|
||||||
documentation=DOCUMENTATION,
|
route_factory=_route_factory,
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider),
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Risk Compliance data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports verified screening-subject data and minimized compliance "
|
||||||
|
"attribution while protecting third-party and legal-review evidence."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="data-assurance",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.data_assurance",
|
||||||
|
icon="database-zap",
|
||||||
|
description="i18n:govoplan-core.product_area.data_assurance_description",
|
||||||
|
surface_ids=("risk_compliance.navigation", "risk_compliance.workspace"),
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="risk_compliance.data-subject-requests",
|
||||||
|
title="Risk and compliance data-subject requests",
|
||||||
|
summary=(
|
||||||
|
"Export verified screening-subject data and accountable activity "
|
||||||
|
"without disclosing third-party sanctions or review evidence."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Risk Compliance correlates screening subject data only through an "
|
||||||
|
"exact, separately verified subject reference. An account or membership "
|
||||||
|
"identifier independently locates the subject's own operator, reviewer, "
|
||||||
|
"import, exception, and assurance-graph attribution. Searches can narrow "
|
||||||
|
"to a screening or assurance revision, but an object identifier alone "
|
||||||
|
"never establishes identity. Subject exports include bounded submitted "
|
||||||
|
"names, aliases, identifiers, dates, addresses, and the screening "
|
||||||
|
"lifecycle outcome. They exclude sanctions-entry data about third "
|
||||||
|
"parties, candidate matching evidence, fingerprints, hashes, policy "
|
||||||
|
"snapshots, reviewer reasons, authority context, provenance, and evidence "
|
||||||
|
"references. Version-pinned screenings, dispositions, exceptions, and "
|
||||||
|
"assurance revisions remain retained legal and accountability evidence."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "access", "audit", "records", "policy"),
|
||||||
|
order=90,
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"risk_compliance.sanctions.screening",
|
||||||
|
"privacy.data-subject-requests",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_screening_subject": (
|
||||||
|
"Returns bounded subject input and lifecycle data for an exact reference."
|
||||||
|
),
|
||||||
|
"exclude_third_party_evidence": (
|
||||||
|
"Never returns sanctions entries, match evidence, or protected review payloads."
|
||||||
|
),
|
||||||
|
"retain_compliance_evidence": (
|
||||||
|
"Preserves version-pinned legal, audit, and accountability history."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen zu Risiko und Compliance",
|
||||||
|
"summary": (
|
||||||
|
"Verifizierte Daten geprüfter Betroffener und verantwortbare Aktivitäten exportieren, "
|
||||||
|
"ohne Sanktions- oder Prüfungsnachweise Dritter offenzulegen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Risk Compliance gleicht Daten geprüfter Betroffener nur über eine exakte, getrennt "
|
||||||
|
"verifizierte Betroffenenreferenz ab. Eine Konto- oder Mitgliedschaftskennung ermittelt "
|
||||||
|
"unabhängig eigene Zuschreibungen zu Bedienung, Prüfung, Import, Ausnahme und Assurance-Graph. "
|
||||||
|
"Suchen können auf eine Prüfungs- oder Assurance-Revision eingegrenzt werden; eine "
|
||||||
|
"Objektkennung allein begründet niemals Identität. Betroffenenexporte enthalten begrenzte "
|
||||||
|
"übermittelte Namen, Aliase, Kennungen, Daten, Adressen und das Lebenszyklusergebnis der Prüfung. "
|
||||||
|
"Sanktionsdaten Dritter, Kandidatenabgleichsnachweise, Fingerabdrücke, Prüfsummen, "
|
||||||
|
"Richtliniensnapshots, Begründungen prüfender Personen, Zuständigkeitskontext, Provenienz und "
|
||||||
|
"Nachweisreferenzen bleiben ausgeschlossen. Versionsgebundene Prüfungen, Dispositionen, Ausnahmen "
|
||||||
|
"und Assurance-Revisionen bleiben als rechtliche und verantwortungsbezogene Nachweise erhalten."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_screening_subject": (
|
||||||
|
"Gibt begrenzte Betroffeneneingaben und Lebenszyklusdaten für eine exakte Referenz zurück."
|
||||||
|
),
|
||||||
|
"exclude_third_party_evidence": (
|
||||||
|
"Gibt niemals Sanktionslisteneinträge, Abgleichsnachweise oder geschützte Prüfungsinhalte zurück."
|
||||||
|
),
|
||||||
|
"retain_compliance_evidence": (
|
||||||
|
"Bewahrt versionsgebundene Rechts-, Audit- und Verantwortungsnachweise auf."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
*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."""
|
||||||
+834
@@ -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],
|
||||||
|
)
|
||||||
+180
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class RiskComplianceDocumentationTests(unittest.TestCase):
|
||||||
|
def test_public_topics_have_complete_german_reference_content(self) -> None:
|
||||||
|
self.assertEqual(2, len(manifest.documentation))
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(translation.get(key) for key in ("title", "summary", "body"))
|
||||||
|
)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningDisposition,
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningRun,
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.dsar_provider import (
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY,
|
||||||
|
RiskComplianceDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: RiskComplianceDsarProvider) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (RISK_COMPLIANCE_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
if name != RISK_COMPLIANCE_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
return "risk_compliance"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type("State", (), {"effective_modules": ("risk_compliance",)})()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
if name != RISK_COMPLIANCE_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "risk_compliance"})(),)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskComplianceDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = RiskComplianceDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
list_snapshot = RiskSanctionsListSnapshot(
|
||||||
|
id="list-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
visibility="tenant",
|
||||||
|
connector_snapshot_ref="connector:snapshot:secret-do-not-export",
|
||||||
|
provider_id="un",
|
||||||
|
publisher="United Nations",
|
||||||
|
jurisdiction="global",
|
||||||
|
list_type="sanctions",
|
||||||
|
source_id="consolidated",
|
||||||
|
source_version="2026-08-22",
|
||||||
|
publication_at=NOW,
|
||||||
|
effective_at=NOW,
|
||||||
|
acquired_at=NOW,
|
||||||
|
sha256="list-sha-do-not-export",
|
||||||
|
connector_run_id="connector-run-do-not-export",
|
||||||
|
raw_evidence_ref="evidence-ref-do-not-export",
|
||||||
|
source_parser_version="parser-v1",
|
||||||
|
normalization_version="normalizer-v1",
|
||||||
|
signature_evidence={"secret": "signature-do-not-export"},
|
||||||
|
provenance={"secret": "provenance-do-not-export"},
|
||||||
|
entry_count=1,
|
||||||
|
status="active",
|
||||||
|
imported_by="account-1",
|
||||||
|
imported_at=NOW,
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
subject_snapshot = RiskScreeningSubjectSnapshot(
|
||||||
|
id="subject-snapshot-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject_ref="party:person-1",
|
||||||
|
subject_type="person",
|
||||||
|
primary_name="Ada Example",
|
||||||
|
normalized_name="ada example normalized-do-not-export",
|
||||||
|
aliases=["Ada E."],
|
||||||
|
identifiers=[
|
||||||
|
{
|
||||||
|
"type": "resident-number",
|
||||||
|
"value": "resident-123",
|
||||||
|
"unexpected": "identifier-extra-do-not-export",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dates=["1990-01-01"],
|
||||||
|
addresses=[
|
||||||
|
{
|
||||||
|
"street": "Example Street 1",
|
||||||
|
"city": "Exampletown",
|
||||||
|
"country": "DE",
|
||||||
|
"unexpected": "address-extra-do-not-export",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
fingerprint="subject-fingerprint-do-not-export",
|
||||||
|
submitted_by="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
run = RiskScreeningRun(
|
||||||
|
id="screening-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject_snapshot_id="subject-snapshot-1",
|
||||||
|
list_snapshot_id="list-1",
|
||||||
|
idempotency_key="screening-idempotency-do-not-export",
|
||||||
|
request_hash="screening-request-hash-do-not-export",
|
||||||
|
matcher_version="matcher-v1",
|
||||||
|
normalization_version="normalizer-v1",
|
||||||
|
policy_version="policy-v1",
|
||||||
|
policy_snapshot={"secret": "policy-snapshot-do-not-export"},
|
||||||
|
status="complete",
|
||||||
|
outcome="review",
|
||||||
|
candidate_count=1,
|
||||||
|
started_at=NOW,
|
||||||
|
completed_at=NOW,
|
||||||
|
created_by="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
sanctions_entry = RiskSanctionsEntry(
|
||||||
|
id="entry-1",
|
||||||
|
snapshot_id="list-1",
|
||||||
|
source_entry_id="third-party-entry-do-not-export",
|
||||||
|
subject_type="person",
|
||||||
|
primary_name="Third Party Name Do Not Export",
|
||||||
|
normalized_name="third party",
|
||||||
|
original_script_name=None,
|
||||||
|
reference_number="third-party-reference-do-not-export",
|
||||||
|
listed_on=None,
|
||||||
|
programmes=[],
|
||||||
|
measures=[],
|
||||||
|
raw_evidence_locator="third-party-evidence-do-not-export",
|
||||||
|
details={"secret": "third-party-details-do-not-export"},
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
candidate = RiskScreeningCandidate(
|
||||||
|
id="candidate-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
run_id="screening-1",
|
||||||
|
entry_id="entry-1",
|
||||||
|
score=91,
|
||||||
|
match_kind="fuzzy",
|
||||||
|
evidence=[{"secret": "candidate-evidence-do-not-export"}],
|
||||||
|
review_status="confirmed",
|
||||||
|
current_disposition_id="disposition-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
disposition = RiskScreeningDisposition(
|
||||||
|
id="disposition-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
candidate_id="candidate-1",
|
||||||
|
decision="false_positive",
|
||||||
|
reason="review-reason-do-not-export",
|
||||||
|
evidence_refs=["review-evidence-do-not-export"],
|
||||||
|
scope="subject_entry",
|
||||||
|
expires_at=NOW + timedelta(days=30),
|
||||||
|
review_at=NOW + timedelta(days=15),
|
||||||
|
actor_account_id="account-1",
|
||||||
|
actor_membership_id="membership-1",
|
||||||
|
actor_authority={"secret": "authority-do-not-export"},
|
||||||
|
separation_status="independent",
|
||||||
|
override_reason="override-reason-do-not-export",
|
||||||
|
created_at=NOW,
|
||||||
|
)
|
||||||
|
exception = RiskScreeningException(
|
||||||
|
id="exception-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject_fingerprint="exception-fingerprint-do-not-export",
|
||||||
|
source_entry_ref="exception-source-entry-do-not-export",
|
||||||
|
scope="subject_entry",
|
||||||
|
status="active",
|
||||||
|
reason="exception-reason-do-not-export",
|
||||||
|
evidence_refs=["exception-evidence-do-not-export"],
|
||||||
|
starts_at=NOW,
|
||||||
|
expires_at=NOW + timedelta(days=30),
|
||||||
|
review_at=NOW + timedelta(days=15),
|
||||||
|
originating_disposition_id="disposition-1",
|
||||||
|
created_by="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
node = RiskAssuranceNode(
|
||||||
|
id="node-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
stable_id="control-1",
|
||||||
|
kind="control",
|
||||||
|
revision=1,
|
||||||
|
label="Sensitive control label do not export",
|
||||||
|
description="Sensitive control description do not export",
|
||||||
|
state="active",
|
||||||
|
owner_ref="owner-secret-do-not-export",
|
||||||
|
scope_ref="scope-secret-do-not-export",
|
||||||
|
governed_object_ref="object-secret-do-not-export",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
provenance={"secret": "node-provenance-do-not-export"},
|
||||||
|
legal_basis_refs=["legal-secret-do-not-export"],
|
||||||
|
policy_refs=["policy-secret-do-not-export"],
|
||||||
|
evidence_refs=["node-evidence-do-not-export"],
|
||||||
|
classification="restricted",
|
||||||
|
created_by="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
edge = RiskAssuranceEdge(
|
||||||
|
id="edge-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
stable_id="relation-1",
|
||||||
|
revision=1,
|
||||||
|
source_node_ref="control-1",
|
||||||
|
target_node_ref="risk-1",
|
||||||
|
relation="mitigates",
|
||||||
|
state="active",
|
||||||
|
owner_ref="edge-owner-secret-do-not-export",
|
||||||
|
scope_ref="edge-scope-secret-do-not-export",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
provenance={"secret": "edge-provenance-do-not-export"},
|
||||||
|
legal_basis_refs=["edge-legal-secret-do-not-export"],
|
||||||
|
policy_refs=["edge-policy-secret-do-not-export"],
|
||||||
|
evidence_refs=["edge-evidence-do-not-export"],
|
||||||
|
created_by="account-1",
|
||||||
|
created_at=NOW,
|
||||||
|
updated_at=NOW,
|
||||||
|
)
|
||||||
|
other_tenant_node = RiskAssuranceNode(
|
||||||
|
id="node-other",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
stable_id="other-control",
|
||||||
|
kind="control",
|
||||||
|
revision=1,
|
||||||
|
label="Other tenant",
|
||||||
|
state="active",
|
||||||
|
owner_ref="owner",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
provenance={},
|
||||||
|
legal_basis_refs=[],
|
||||||
|
policy_refs=[],
|
||||||
|
evidence_refs=[],
|
||||||
|
classification="internal",
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
list_snapshot,
|
||||||
|
subject_snapshot,
|
||||||
|
run,
|
||||||
|
sanctions_entry,
|
||||||
|
candidate,
|
||||||
|
disposition,
|
||||||
|
exception,
|
||||||
|
node,
|
||||||
|
edge,
|
||||||
|
other_tenant_node,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subject() -> DsarSubjectRef:
|
||||||
|
return DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
external_references={
|
||||||
|
"risk_compliance.subject": "party:person-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_exports_subject_data_and_minimized_attribution(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"screening_subject_submission",
|
||||||
|
"screening_actor_attribution",
|
||||||
|
"snapshot_import_actor_attribution",
|
||||||
|
"disposition_actor_attribution",
|
||||||
|
"exception_actor_attribution",
|
||||||
|
"assurance_node_actor_attribution",
|
||||||
|
"assurance_edge_actor_attribution",
|
||||||
|
},
|
||||||
|
{record.resource_type for record in records},
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertIn("Ada Example", exported)
|
||||||
|
self.assertIn("resident-123", exported)
|
||||||
|
for excluded in (
|
||||||
|
"normalized-do-not-export",
|
||||||
|
"identifier-extra-do-not-export",
|
||||||
|
"address-extra-do-not-export",
|
||||||
|
"subject-fingerprint-do-not-export",
|
||||||
|
"screening-idempotency-do-not-export",
|
||||||
|
"screening-request-hash-do-not-export",
|
||||||
|
"policy-snapshot-do-not-export",
|
||||||
|
"Third Party Name Do Not Export",
|
||||||
|
"third-party-reference-do-not-export",
|
||||||
|
"candidate-evidence-do-not-export",
|
||||||
|
"review-reason-do-not-export",
|
||||||
|
"review-evidence-do-not-export",
|
||||||
|
"authority-do-not-export",
|
||||||
|
"override-reason-do-not-export",
|
||||||
|
"exception-fingerprint-do-not-export",
|
||||||
|
"exception-source-entry-do-not-export",
|
||||||
|
"exception-reason-do-not-export",
|
||||||
|
"Sensitive control label do not export",
|
||||||
|
"node-provenance-do-not-export",
|
||||||
|
"edge-owner-secret-do-not-export",
|
||||||
|
"edge-evidence-do-not-export",
|
||||||
|
"list-sha-do-not-export",
|
||||||
|
"signature-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(excluded, exported)
|
||||||
|
|
||||||
|
def test_subject_data_requires_exact_module_reference(self) -> None:
|
||||||
|
account_only = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
)
|
||||||
|
reference_only = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"risk_compliance.subject": "party:person-1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"screening_subject_submission",
|
||||||
|
{record.resource_type for record in account_only},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"screening_subject_submission"},
|
||||||
|
{record.resource_type for record in reference_only},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_conflicts_narrowing_and_tenant_boundaries(self) -> None:
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={
|
||||||
|
"risk_compliance.account": "account-other",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
narrowed = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={
|
||||||
|
"risk_compliance.subject": "party:person-1",
|
||||||
|
"risk_compliance.screening": "screening-1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
full = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||||
|
)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"screening_subject_submission",
|
||||||
|
"screening_actor_attribution",
|
||||||
|
"disposition_actor_attribution",
|
||||||
|
},
|
||||||
|
{record.resource_type for record in narrowed},
|
||||||
|
)
|
||||||
|
self.assertNotIn("node-other", {record.resource_id for record in full})
|
||||||
|
|
||||||
|
def test_erasure_retains_legal_and_accountability_evidence(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self._subject(),
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertTrue(actions)
|
||||||
|
self.assertTrue(
|
||||||
|
all(action.kind == "retain" and not action.executable for action in actions)
|
||||||
|
)
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self._subject(),
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-risk-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||||
|
|
||||||
|
def test_manifest_and_core_workflow_discover_provider(self) -> None:
|
||||||
|
self.assertIn(RISK_COMPLIANCE_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
"risk_compliance.data-subject-requests",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-RISK-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self._subject(),
|
||||||
|
purpose="Risk screening access request",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="operator-1",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=row.resource_revision,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", row.status)
|
||||||
|
self.assertEqual(7, row.search_result["record_count"])
|
||||||
|
|
||||||
|
|
||||||
|
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
@@ -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.dsar_provider import (
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY,
|
||||||
|
)
|
||||||
|
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)
|
{permission.scope for permission in manifest.permissions},
|
||||||
self.assertIsNone(manifest.route_factory)
|
{
|
||||||
self.assertIsNone(manifest.migration_spec)
|
READ_SCOPE,
|
||||||
self.assertIsNone(manifest.frontend)
|
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",
|
||||||
|
RISK_COMPLIANCE_DSAR_CAPABILITY,
|
||||||
|
},
|
||||||
|
{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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/risk-compliance-webui",
|
||||||
|
"version": "0.1.19",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, riskComplianceModule } from "./module";
|
||||||
|
export * from "./api/riskCompliance";
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
.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: var(--radius-md);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-dark);
|
||||||
|
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-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-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
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-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-score {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 38px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
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: 900px) {
|
||||||
|
.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-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user