Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.16",
|
||||||
"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.16",
|
||||||
|
"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.16"
|
||||||
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.16",
|
||||||
|
"govoplan-access>=0.1.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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
@@ -1,14 +1,68 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
|
||||||
MODULE_ID = "risk-compliance"
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.sanctions import (
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
ModuleArchitectureDocumentation,
|
||||||
|
ModuleMaturityEvidence,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_risk_compliance.backend.db.models import (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
|
RiskSanctionsAddress,
|
||||||
|
RiskSanctionsAlias,
|
||||||
|
RiskSanctionsDate,
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskSanctionsIdentifier,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningDisposition,
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningRun,
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
)
|
||||||
|
from govoplan_risk_compliance.backend.permissions import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "risk_compliance"
|
||||||
MODULE_NAME = "Risk Compliance"
|
MODULE_NAME = "Risk Compliance"
|
||||||
MODULE_VERSION = "0.1.7"
|
MODULE_VERSION = "0.1.16"
|
||||||
READ_SCOPE = "risk-compliance:workspace:read"
|
|
||||||
WRITE_SCOPE = "risk-compliance:workspace:write"
|
|
||||||
ADMIN_SCOPE = "risk-compliance:workspace:admin"
|
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"audit",
|
"audit",
|
||||||
"policy",
|
"policy",
|
||||||
@@ -17,10 +71,96 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"files",
|
"files",
|
||||||
"tasks",
|
"tasks",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"connectors",
|
||||||
|
)
|
||||||
|
_PERSISTENT_MODELS = (
|
||||||
|
RiskAssuranceEdge,
|
||||||
|
RiskAssuranceNode,
|
||||||
|
RiskScreeningException,
|
||||||
|
RiskScreeningDisposition,
|
||||||
|
RiskScreeningCandidate,
|
||||||
|
RiskScreeningRun,
|
||||||
|
RiskScreeningSubjectSnapshot,
|
||||||
|
RiskSanctionsAddress,
|
||||||
|
RiskSanctionsDate,
|
||||||
|
RiskSanctionsIdentifier,
|
||||||
|
RiskSanctionsAlias,
|
||||||
|
RiskSanctionsEntry,
|
||||||
|
RiskSanctionsListSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||||
|
layer="governance_accountability",
|
||||||
|
kind="governance",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
evidence=(
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="test",
|
||||||
|
reference="tests/test_assurance_graph.py",
|
||||||
|
summary=(
|
||||||
|
"Exercises tenant-safe immutable graph revisions, bounded traversal, "
|
||||||
|
"sanctions projection, synthetic controls, ACL, and search."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="test",
|
||||||
|
reference="tests/test_sanctions_screening.py",
|
||||||
|
summary=(
|
||||||
|
"Exercises immutable sanctions evidence, deterministic matching, "
|
||||||
|
"review, exceptions, and freshness gates."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="migration",
|
||||||
|
reference="tests/test_migrations.py",
|
||||||
|
summary=(
|
||||||
|
"Exercises the persistent sanctions screening and assurance "
|
||||||
|
"graph schemas."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="documentation",
|
||||||
|
reference="docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",
|
||||||
|
summary="Defines assurance ownership and integration boundaries.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
known_limits=(
|
||||||
|
"The assurance graph provides generic governance primitives; domain modules still own corrective execution.",
|
||||||
|
"Cross-tenant aggregate assurance is intentionally not exposed by the tenant API.",
|
||||||
|
),
|
||||||
|
supported_authority_modes=(
|
||||||
|
"native_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governance_overlay",
|
||||||
|
"linked_reference",
|
||||||
|
),
|
||||||
|
owned_concepts=(
|
||||||
|
"risk and control evaluation",
|
||||||
|
"sanctions screening runs",
|
||||||
|
"candidate review and dispositions",
|
||||||
|
"compliance findings and assurance review",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"external source transport and credentials",
|
||||||
|
"immutable audit event storage",
|
||||||
|
"policy rule evaluation",
|
||||||
|
"governed domain objects and corrective execution",
|
||||||
|
),
|
||||||
|
documentation=ModuleArchitectureDocumentation(
|
||||||
|
migration=("tests/test_migrations.py",),
|
||||||
|
upgrade=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
|
||||||
|
recovery=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
|
||||||
|
security=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
|
||||||
|
operations=("docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(
|
||||||
|
scope: str,
|
||||||
|
label: str,
|
||||||
|
description: str,
|
||||||
|
) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
scope=scope,
|
scope=scope,
|
||||||
@@ -35,52 +175,223 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View risk compliance workspace", "Read risk compliance records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage risk compliance workspace", "Create and update risk compliance records and workflow state."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer risk compliance workspace", "Configure risk compliance policies, templates, and tenant-level administration."),
|
"View risk compliance workspace",
|
||||||
|
"Read risk compliance records, configuration, and workflow context.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Manage risk compliance workspace",
|
||||||
|
"Create and update risk compliance records and workflow state.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Administer risk compliance workspace",
|
||||||
|
"Configure risk compliance policies, templates, and tenant administration.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
"View sanctions screening evidence",
|
||||||
|
"Read list snapshots, screening runs, and candidate evidence.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
"Run sanctions screening",
|
||||||
|
"Submit subjects for deterministic screening against an immutable list.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
"Review sanctions candidates",
|
||||||
|
"Record evidence-backed candidate dispositions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
"Administer sanctions screening",
|
||||||
|
"Import source snapshots, configure policy, and authorize review overrides.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="risk_compliance_manager",
|
slug="risk_compliance_manager",
|
||||||
name="Risk Compliance manager",
|
name="Risk Compliance manager",
|
||||||
description="Manage risk compliance records and workflow state.",
|
description=("Manage compliance workflows and administer sanctions screening."),
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
permissions=(
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="risk_compliance_reviewer",
|
||||||
|
name="Risk Compliance reviewer",
|
||||||
|
description=("Run screenings and independently review potential matches."),
|
||||||
|
permissions=(
|
||||||
|
READ_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="risk_compliance_viewer",
|
slug="risk_compliance_viewer",
|
||||||
name="Risk Compliance viewer",
|
name="Risk Compliance viewer",
|
||||||
description="Read risk compliance records and workflow context.",
|
description="Read risk compliance records and screening evidence.",
|
||||||
permissions=(READ_SCOPE,),
|
permissions=(READ_SCOPE, SANCTIONS_READ_SCOPE),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_factory(_context):
|
||||||
|
from govoplan_risk_compliance.backend.router import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _sanctions_screening_provider(_context):
|
||||||
|
from govoplan_risk_compliance.backend.capabilities import (
|
||||||
|
RiskComplianceSanctionsScreeningProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RiskComplianceSanctionsScreeningProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _assurance_search_source(context):
|
||||||
|
from govoplan_risk_compliance.backend.search_source import (
|
||||||
|
create_risk_assurance_search_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_risk_assurance_search_source(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"risk_sanctions_list_snapshots": (
|
||||||
|
session.query(RiskSanctionsListSnapshot)
|
||||||
|
.filter(RiskSanctionsListSnapshot.tenant_id == tenant_id)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"risk_screening_runs": (
|
||||||
|
session.query(RiskScreeningRun)
|
||||||
|
.filter(RiskScreeningRun.tenant_id == tenant_id)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"risk_pending_screening_candidates": (
|
||||||
|
session.query(RiskScreeningCandidate)
|
||||||
|
.filter(
|
||||||
|
RiskScreeningCandidate.tenant_id == tenant_id,
|
||||||
|
RiskScreeningCandidate.review_status.in_(
|
||||||
|
("pending", "exception_review")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
"risk_assurance_nodes": (
|
||||||
|
session.query(RiskAssuranceNode)
|
||||||
|
.filter(
|
||||||
|
RiskAssuranceNode.tenant_id == tenant_id,
|
||||||
|
RiskAssuranceNode.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
DOCUMENTATION = (
|
DOCUMENTATION = (
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
id=f"{MODULE_ID}.module-boundary",
|
||||||
title=f"{MODULE_NAME} module boundary",
|
title=f"{MODULE_NAME} module boundary",
|
||||||
summary="Risk and compliance workflows for data protection incidents, DPIAs, compliance controls, audit measures, risk registers, and internal-control evidence.",
|
summary=(
|
||||||
|
"Risk and compliance workflows own legal evaluation, immutable "
|
||||||
|
"screening evidence, review, and dispositions."
|
||||||
|
),
|
||||||
body=(
|
body=(
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
"Connectors may acquire source evidence, but Risk Compliance "
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
"owns immutable normalized sanctions lists, version-pinned "
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
"screening, candidate review, and legal dispositions. Fuzzy "
|
||||||
|
"matching only creates candidates and never confirms a match. "
|
||||||
|
"The broader module direction links obligations, governed object "
|
||||||
|
"references, risks, controls, evidence, findings, corrective "
|
||||||
|
"measures, and effectiveness reviews without copying the governed "
|
||||||
|
"domain object or replacing Policy and Audit."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=(
|
||||||
|
"operator",
|
||||||
|
"module_admin",
|
||||||
|
"compliance_reviewer",
|
||||||
|
),
|
||||||
order=100,
|
order=100,
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Repository domain boundary",
|
label="Repository domain boundary",
|
||||||
href="govoplan-risk-compliance/docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md",
|
href=(
|
||||||
|
"govoplan-risk-compliance/docs/RISK_COMPLIANCE_DOMAIN_BOUNDARY.md"
|
||||||
|
),
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Interface pattern migration",
|
||||||
|
href=(
|
||||||
|
"govoplan-risk-compliance/docs/INTERFACE_PATTERN_MIGRATION.md"
|
||||||
|
),
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"domain_objects": [
|
||||||
"domain_objects": ['risk registers', 'compliance controls', 'DPIA records', 'data protection incident records', 'audit measures', 'internal-control evidence'],
|
"sanctions list snapshots",
|
||||||
"first_slice": "Define risk register, control, evidence, DPIA, incident, measure, and review-cycle concepts.",
|
"screening runs",
|
||||||
|
"candidate evidence",
|
||||||
|
"review dispositions",
|
||||||
|
"time-bounded exceptions",
|
||||||
|
],
|
||||||
|
"privacy": (
|
||||||
|
"Queue and audit summaries contain stable references and "
|
||||||
|
"minimal subject data."
|
||||||
|
),
|
||||||
|
"assurance_domain_model": [
|
||||||
|
"obligation",
|
||||||
|
"governed object reference",
|
||||||
|
"risk",
|
||||||
|
"control",
|
||||||
|
"evidence",
|
||||||
|
"finding",
|
||||||
|
"corrective measure",
|
||||||
|
"effectiveness review",
|
||||||
|
],
|
||||||
|
"assurance_graph": (
|
||||||
|
"Every node and edge is effective-dated, revisioned, tenant-scoped, "
|
||||||
|
"and linked through opaque governed-object references."
|
||||||
|
),
|
||||||
|
"help_contexts": [
|
||||||
|
"risk_compliance.workspace",
|
||||||
|
"risk_compliance.sanctions.sources",
|
||||||
|
"risk_compliance.action.import-snapshot",
|
||||||
|
"risk_compliance.sanctions.screening",
|
||||||
|
"risk_compliance.action.run-screening",
|
||||||
|
"risk_compliance.sanctions.review",
|
||||||
|
"risk_compliance.review.disposition",
|
||||||
|
"risk_compliance.assurance.graph",
|
||||||
|
"risk_compliance.assurance.editor",
|
||||||
|
"risk_compliance.action.connect-assurance",
|
||||||
|
"risk_compliance.state.source-unavailable",
|
||||||
|
"risk_compliance.state.read-only",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"import_snapshot": "copy connector evidence into an immutable normalized sanctions-list snapshot",
|
||||||
|
"run_screening": "create immutable version-pinned screening evidence from the minimum submitted subject data",
|
||||||
|
"record_disposition": "append an evidence-backed legal review disposition that is not edited in place",
|
||||||
|
"record_exception": "append a time-bounded subject-and-entry exception with explicit expiry",
|
||||||
|
"revise_assurance_object": "append a new effective-dated revision while preserving prior evidence",
|
||||||
|
"connect_assurance_objects": "append a governed typed relationship between assurance objects",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -91,12 +402,177 @@ manifest = ModuleManifest(
|
|||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="risk_compliance.sanctions_screening",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="connectors.sanctions_snapshots",
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
route_factory=_route_factory,
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING: (_sanctions_screening_provider),
|
||||||
|
},
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="risk_compliance.assurance",
|
||||||
|
factory=_assurance_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/risk-compliance-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/risk-compliance",
|
||||||
|
component="RiskCompliancePage",
|
||||||
|
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
|
||||||
|
order=115,
|
||||||
|
surface_id="risk_compliance.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/risk-compliance",
|
||||||
|
label="Risk Compliance",
|
||||||
|
icon="shield-check",
|
||||||
|
required_any=(READ_SCOPE, SANCTIONS_READ_SCOPE),
|
||||||
|
order=115,
|
||||||
|
surface_id="risk_compliance.navigation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.sanctions.sources",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Sanctions source snapshots",
|
||||||
|
parent_id="risk_compliance.workspace",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.sanctions.screening",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Sanctions screening",
|
||||||
|
parent_id="risk_compliance.workspace",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.sanctions.review",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Sanctions review queue",
|
||||||
|
parent_id="risk_compliance.workspace",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.assurance.graph",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Assurance graph",
|
||||||
|
parent_id="risk_compliance.workspace",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.action.import-snapshot",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="action",
|
||||||
|
label="Import sanctions source snapshot",
|
||||||
|
parent_id="risk_compliance.sanctions.sources",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.action.run-screening",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="action",
|
||||||
|
label="Run sanctions screening",
|
||||||
|
parent_id="risk_compliance.sanctions.screening",
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.review.disposition",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="dialog",
|
||||||
|
label="Record screening disposition",
|
||||||
|
parent_id="risk_compliance.sanctions.review",
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.assurance.editor",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="dialog",
|
||||||
|
label="Assurance object editor",
|
||||||
|
parent_id="risk_compliance.assurance.graph",
|
||||||
|
order=90,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="risk_compliance.action.connect-assurance",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="action",
|
||||||
|
label="Connect assurance objects",
|
||||||
|
parent_id="risk_compliance.assurance.graph",
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
architecture=ARCHITECTURE,
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
migration_after=("connectors",),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
*_PERSISTENT_MODELS,
|
||||||
|
label="Risk Compliance",
|
||||||
|
),
|
||||||
|
retirement_notes=(
|
||||||
|
"Destructive retirement removes immutable assurance graph, "
|
||||||
|
"sanctions list, screening, and review evidence after a database "
|
||||||
|
"snapshot."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
*_PERSISTENT_MODELS,
|
||||||
|
label="Risk Compliance",
|
||||||
|
),
|
||||||
|
),
|
||||||
documentation=DOCUMENTATION,
|
documentation=DOCUMENTATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ADMIN_SCOPE",
|
||||||
|
"MODULE_ID",
|
||||||
|
"MODULE_VERSION",
|
||||||
|
"PERMISSIONS",
|
||||||
|
"READ_SCOPE",
|
||||||
|
"ROLE_TEMPLATES",
|
||||||
|
"SANCTIONS_ADMIN_SCOPE",
|
||||||
|
"SANCTIONS_READ_SCOPE",
|
||||||
|
"SANCTIONS_REVIEW_SCOPE",
|
||||||
|
"SANCTIONS_SCREEN_SCOPE",
|
||||||
|
"WRITE_SCOPE",
|
||||||
|
"get_manifest",
|
||||||
|
"manifest",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Risk Compliance database migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Risk Compliance Alembic revisions."""
|
||||||
+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,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.manifest import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
get_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManifestSeedTests(unittest.TestCase):
|
class ManifestTests(unittest.TestCase):
|
||||||
def test_manifest_registers_seed_contract(self) -> None:
|
def test_manifest_registers_runtime_contract(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "risk-compliance")
|
self.assertEqual(manifest.id, "risk_compliance")
|
||||||
self.assertEqual(manifest.name, "Risk Compliance")
|
self.assertEqual(manifest.name, "Risk Compliance")
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
self.assertEqual(manifest.dependencies, ("access",))
|
||||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
self.assertIn("connectors", manifest.optional_dependencies)
|
||||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"risk_compliance_manager", "risk_compliance_viewer"})
|
self.assertEqual(
|
||||||
self.assertTrue(manifest.documentation)
|
{
|
||||||
self.assertIsNone(manifest.route_factory)
|
permission.scope
|
||||||
self.assertIsNone(manifest.migration_spec)
|
for permission in manifest.permissions
|
||||||
self.assertIsNone(manifest.frontend)
|
},
|
||||||
|
{
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
SANCTIONS_READ_SCOPE,
|
||||||
|
SANCTIONS_SCREEN_SCOPE,
|
||||||
|
SANCTIONS_REVIEW_SCOPE,
|
||||||
|
SANCTIONS_ADMIN_SCOPE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"risk_compliance_reviewer",
|
||||||
|
{role.slug for role in manifest.role_templates},
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||||
|
self.assertIn(
|
||||||
|
"governance_overlay",
|
||||||
|
manifest.architecture.supported_authority_modes,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"connectors.sanctions_snapshots",
|
||||||
|
manifest.requires_interfaces[0].name,
|
||||||
|
)
|
||||||
|
self.assertTrue(manifest.requires_interfaces[0].optional)
|
||||||
|
self.assertEqual(
|
||||||
|
{"risk_compliance.sanctions_screening"},
|
||||||
|
{
|
||||||
|
item.name
|
||||||
|
for item in manifest.provides_interfaces
|
||||||
|
},
|
||||||
|
)
|
||||||
|
capability = manifest.capability_factories[
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
](None)
|
||||||
|
self.assertIsInstance(capability, SanctionsScreeningProvider)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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.16",
|
||||||
|
"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.16",
|
||||||
|
"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,538 @@
|
|||||||
|
.risk-page {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 50px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-toolbar .segmented-control-option {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-count {
|
||||||
|
min-width: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-alerts {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-workspace {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-source-layout,
|
||||||
|
.risk-screen-layout,
|
||||||
|
.risk-review-layout,
|
||||||
|
.risk-assurance-layout {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-source-layout,
|
||||||
|
.risk-screen-layout {
|
||||||
|
grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.35fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-review-layout {
|
||||||
|
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-layout {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-summary {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-metrics {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-columns {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: minmax(300px, 0.75fr) minmax(480px, 1.55fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-filter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.45fr);
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-filter input,
|
||||||
|
.risk-assurance-filter select,
|
||||||
|
.risk-assurance-form input,
|
||||||
|
.risk-assurance-form select,
|
||||||
|
.risk-assurance-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-detail-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-detail-body > p {
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-properties {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-properties > div {
|
||||||
|
min-width: 0;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-properties > div:nth-child(2n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-properties dt {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-properties dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links-header > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links-header span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links > button {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: minmax(110px, 0.45fr) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links > button:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links > button span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-links > button strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-truncated {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
color: var(--warning);
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel > header > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel > header strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel > header span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list,
|
||||||
|
.risk-result-body,
|
||||||
|
.risk-evidence-body {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list-row,
|
||||||
|
.risk-candidate-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 58px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-queue-row {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list > .selection-list {
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list-main {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list-main strong,
|
||||||
|
.risk-list-main span,
|
||||||
|
.risk-list-main code {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list-main strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-list-main span,
|
||||||
|
.risk-list-main code {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-form-body {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-form-body input,
|
||||||
|
.risk-form-body select,
|
||||||
|
.risk-disposition-form input,
|
||||||
|
.risk-disposition-form select,
|
||||||
|
.risk-disposition-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-form-body .btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-metrics > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-metrics > span:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-metrics strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-strong);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-score {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 38px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-candidate-summary > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-candidate-summary span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--success);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-comparison {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-column {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-column:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-column > strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-column > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-values {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-values span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-values strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-match-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-match-evidence span,
|
||||||
|
.risk-match-evidence code {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-disposition-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.risk-workspace {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-source-layout,
|
||||||
|
.risk-screen-layout,
|
||||||
|
.risk-review-layout,
|
||||||
|
.risk-assurance-layout,
|
||||||
|
.risk-assurance-columns {
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-form-grid,
|
||||||
|
.risk-assurance-properties {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-assurance-properties > div {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-panel {
|
||||||
|
min-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-comparison {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-evidence-column {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user