Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71434ec6d | ||
|
|
869891f7ec | ||
|
|
7b43816b22 | ||
|
|
f07d12fa52 | ||
|
|
42a800a7dc | ||
|
|
6e63f6920a | ||
|
|
1d993b26c8 | ||
|
|
4613b54c06 | ||
|
|
6f095eb562 | ||
|
|
0cbb249d74 | ||
|
|
f0d2916d46 | ||
|
|
c1e6c15866 | ||
|
|
451361cc05 | ||
|
|
6d3fcc1572 | ||
|
|
4177287b22 | ||
|
|
57ceef0173 | ||
|
|
078e9144b1 | ||
|
|
f74e8cf85b | ||
|
|
0167ab752a | ||
|
|
1479946729 | ||
|
|
130f738970 | ||
|
|
a34935da02 | ||
|
|
86b20c65cb | ||
|
|
a96dc228b8 | ||
|
|
5e4f84a789 |
@@ -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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# GovOPlaN Audit Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns durable audit records, audit administration surfaces, retention behavior, and the transactional platform-event outbox.
|
||||||
|
|
||||||
|
## 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 Audit internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Store bounded evidence and trace context, not arbitrary feature payloads.
|
||||||
|
- Preserve transactional recording, retention, redaction, and retry guarantees.
|
||||||
@@ -11,6 +11,15 @@ This repository owns the live `audit_log` table, audit API route
|
|||||||
contributions, the `@govoplan/audit-webui` package, and the target boundary
|
contributions, the `@govoplan/audit-webui` package, and the target boundary
|
||||||
for future audit sink/export capability work.
|
for future audit sink/export capability work.
|
||||||
|
|
||||||
|
Audit now also owns versioned evidence-bundle export. Authorized auditors can
|
||||||
|
select a bounded tenant or system record set, attach module-owned external
|
||||||
|
evidence references, and download a redacted artifact with canonical hashes
|
||||||
|
and optional Ed25519 signatures. The `govoplan-audit-verify` command validates
|
||||||
|
the bundle offline and reports unsupported, incomplete, unverifiable, and
|
||||||
|
tampered evidence separately. See
|
||||||
|
[docs/EVIDENCE_BUNDLES.md](docs/EVIDENCE_BUNDLES.md) for API permissions,
|
||||||
|
signing configuration, module contribution rules, and verifier usage.
|
||||||
|
|
||||||
The WebUI package contributes the `system-audit` and `tenant-audit` admin
|
The WebUI package contributes the `system-audit` and `tenant-audit` admin
|
||||||
sections through the shared `admin.sections` UI capability. The admin shell
|
sections through the shared `admin.sections` UI capability. The admin shell
|
||||||
does not render audit panels unless this module is installed and enabled.
|
does not render audit panels unless this module is installed and enabled.
|
||||||
@@ -26,3 +35,7 @@ foundation:
|
|||||||
See [docs/AUDIT_TRACE_CONTEXT.md](docs/AUDIT_TRACE_CONTEXT.md) for the standard
|
See [docs/AUDIT_TRACE_CONTEXT.md](docs/AUDIT_TRACE_CONTEXT.md) for the standard
|
||||||
operational context fields used by admin, installer, and module lifecycle audit
|
operational context fields used by admin, installer, and module lifecycle audit
|
||||||
entries.
|
entries.
|
||||||
|
|
||||||
|
The administration surface archetypes, consequence classification, and
|
||||||
|
verification contract are recorded in
|
||||||
|
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|||||||
@@ -93,12 +93,24 @@ Commands and events are separate concepts:
|
|||||||
be written to the audit outbox before delivery.
|
be written to the audit outbox before delivery.
|
||||||
|
|
||||||
`govoplan_audit.backend.outbox.SqlAuditOutbox` persists platform events in
|
`govoplan_audit.backend.outbox.SqlAuditOutbox` persists platform events in
|
||||||
`audit_outbox_events`. Dispatchers can later call
|
`audit_outbox_events` and one durable state row per allowlisted consumer in
|
||||||
`dispatch_pending_platform_events()` to publish pending events and record retry
|
`audit_outbox_deliveries`. Dispatchers supply stable consumer IDs and
|
||||||
state. The outbox payload stores the full governed event envelope:
|
idempotent handlers. Consumer work and its delivered marker share one database
|
||||||
|
transaction; retries reuse the stable `<event-id>:<consumer-id>` delivery key.
|
||||||
|
Bounded failures are quarantined instead of retried forever. The outbox payload
|
||||||
|
stores the full governed event envelope:
|
||||||
correlation/causation ids, actor, tenant, subject, resource, classification,
|
correlation/causation ids, actor, tenant, subject, resource, classification,
|
||||||
module id, event id, type, and payload.
|
module id, event id, type, and payload.
|
||||||
|
|
||||||
|
Public and internal events may use an allowlisted subscription directly.
|
||||||
|
Confidential and restricted subscriptions additionally require a persisted
|
||||||
|
policy-decision reference. Operators can inspect delivery metrics at
|
||||||
|
`GET /api/v1/admin/audit/event-delivery/metrics` and replay a retrying or
|
||||||
|
quarantined delivery with a reason through
|
||||||
|
`POST /api/v1/admin/audit/event-deliveries/{event_id}/{consumer_id}/replay`.
|
||||||
|
Replay itself is written to the audit log. Successful envelopes are subject to
|
||||||
|
configured retention; quarantined evidence is not removed automatically.
|
||||||
|
|
||||||
Application code should enqueue or publish facts only after the state change
|
Application code should enqueue or publish facts only after the state change
|
||||||
they describe is known. Long-running operators and installers should model
|
they describe is known. Long-running operators and installers should model
|
||||||
requested work as commands first, then emit facts as events as each step
|
requested work as commands first, then emit facts as events as each step
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Audit evidence bundles
|
||||||
|
|
||||||
|
Audit evidence bundles are bounded, portable JSON artifacts for independent
|
||||||
|
review. They are not database backups. A bundle contains selected audit facts,
|
||||||
|
trace context, policy and source provenance, external evidence references, and
|
||||||
|
redaction declarations. It never embeds referenced files, raw messages, full
|
||||||
|
recipient lists, credentials, tokens, or arbitrary feature payloads.
|
||||||
|
|
||||||
|
## Export lifecycle and permissions
|
||||||
|
|
||||||
|
Create an export with `POST /api/v1/admin/audit/evidence-bundles`. A tenant
|
||||||
|
export requires `audit:evidence:export` and is constrained to the principal's
|
||||||
|
active tenant. `system` and `all` scopes require
|
||||||
|
`audit:system_evidence:export`. Selection is limited to 500 audit records and
|
||||||
|
200 external references. An over-broad selection is rejected instead of being
|
||||||
|
silently truncated.
|
||||||
|
|
||||||
|
The response records the `pending`, `ready`, or `failed` lifecycle state and a
|
||||||
|
canonical bundle SHA-256. Metadata and content are available at:
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/audit/evidence-bundles/{id}`
|
||||||
|
- `GET /api/v1/admin/audit/evidence-bundles/{id}/download`
|
||||||
|
|
||||||
|
The Audit administration page offers **Export page evidence** to create an
|
||||||
|
unsigned bundle for the currently displayed records. Use the API when a review
|
||||||
|
needs a broader filtered selection, explicit module references, or signing.
|
||||||
|
|
||||||
|
Generation and every download produce separate audit records. Download access
|
||||||
|
is re-authorized against the original scope so a tenant switch cannot expose a
|
||||||
|
bundle from another tenant. Audit also checks the stored bundle against its
|
||||||
|
persisted canonical hash before each download and fails the lifecycle record if
|
||||||
|
storage integrity no longer matches.
|
||||||
|
|
||||||
|
## Module evidence references
|
||||||
|
|
||||||
|
Feature modules keep their evidence and storage ownership. They may record a
|
||||||
|
serialized `govoplan_core.core.institutional.EvidenceReference` in bounded
|
||||||
|
audit details or supply an external reference in the export request. The Audit
|
||||||
|
module stores only its id, kind, owner module, locator, required flag, and
|
||||||
|
optional content SHA-256. This lets modules participate without importing Audit
|
||||||
|
internals or handing Audit file contents.
|
||||||
|
|
||||||
|
External references should include a SHA-256 whenever the referenced artifact
|
||||||
|
can be canonicalized. A missing hash is reported as unverifiable. A required
|
||||||
|
artifact that is not supplied during offline review is reported as missing,
|
||||||
|
and supplied bytes that do not match their hash are reported as tampered.
|
||||||
|
|
||||||
|
## Signatures
|
||||||
|
|
||||||
|
Canonical record and reference hashes are always emitted. Trusted signatures
|
||||||
|
are optional and use Ed25519. To enable signed exports, configure both:
|
||||||
|
|
||||||
|
- `GOVOPLAN_AUDIT_EVIDENCE_SIGNING_KEY_ID`
|
||||||
|
- `GOVOPLAN_AUDIT_EVIDENCE_SIGNING_PRIVATE_KEY` (path to a PEM Ed25519 key)
|
||||||
|
|
||||||
|
The request must set `sign` to `true`; otherwise the bundle remains unsigned.
|
||||||
|
Keep private keys outside the application database and distribute raw,
|
||||||
|
base64-encoded Ed25519 public keys to independent reviewers through a separate
|
||||||
|
trusted channel.
|
||||||
|
|
||||||
|
## Offline verification
|
||||||
|
|
||||||
|
The installed `govoplan-audit-verify` command requires no source database:
|
||||||
|
|
||||||
|
```text
|
||||||
|
govoplan-audit-verify bundle.json --pretty
|
||||||
|
govoplan-audit-verify bundle.json \
|
||||||
|
--trusted-key institution-2026=BASE64_PUBLIC_KEY \
|
||||||
|
--external decision-42=/review/decision-42.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is deterministic and returns one of these states:
|
||||||
|
|
||||||
|
- `valid`: schema, canonical hashes, completeness, redaction declarations,
|
||||||
|
supplied external evidence, and any trusted signatures are valid.
|
||||||
|
- `incomplete`: a manifest item or required external artifact is missing.
|
||||||
|
- `unverifiable`: a reference lacks a checksum or a signature key is untrusted.
|
||||||
|
- `tampered`: a canonical item, manifest, external artifact, or trusted
|
||||||
|
signature does not match.
|
||||||
|
- `invalid`: the supported schema or redaction contract is malformed.
|
||||||
|
- `unsupported`: the schema or version is not supported by this verifier.
|
||||||
|
|
||||||
|
An unsigned bundle can still be `valid`: the verifier establishes internal
|
||||||
|
hash consistency, while provenance trust must then be established by the
|
||||||
|
review process.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Audit Interface Pattern Migration
|
||||||
|
|
||||||
|
Audit contributes two read-only administration surfaces through the shared
|
||||||
|
`admin.sections` capability. Both use the platform's monitoring and evidence
|
||||||
|
archetype.
|
||||||
|
|
||||||
|
## Surface Map
|
||||||
|
|
||||||
|
| Surface | Authority | Pattern | Consequence class |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `audit.admin.system` | `system:audit:read` | Server-filtered evidence grid and event inspector | Read-only evidence disclosure |
|
||||||
|
| `audit.admin.tenant` | `audit:read` for the active tenant | Server-filtered evidence grid and event inspector | Read-only evidence disclosure |
|
||||||
|
|
||||||
|
Audit does not expose mutation or destructive actions in these panels. The
|
||||||
|
only row action opens an inspection dialog; reload preserves the stable shell
|
||||||
|
and existing evidence while a newer projection is requested.
|
||||||
|
|
||||||
|
## Interaction Contract
|
||||||
|
|
||||||
|
- Core owns the admin layout, DataGrid, dialog, action group, loading/error
|
||||||
|
treatment, disabled-action explanation, and documentation link.
|
||||||
|
- Filtering, sorting, counts, and paging are server-owned. The first page may
|
||||||
|
apply bounded delta updates using an opaque watermark; a full response
|
||||||
|
remains authoritative when the delta contract cannot be used.
|
||||||
|
- System and tenant panels remain distinct and are registered only with their
|
||||||
|
respective read scopes. Tenant selection is never accepted as a free-form
|
||||||
|
client override.
|
||||||
|
- The event inspector renders stable actor, action, object, tenant, timestamp,
|
||||||
|
and structured detail rows. It does not add editing, replay, export, or raw
|
||||||
|
credential access.
|
||||||
|
- Contextual help resolves through `audit.read-authorized-evidence`; operational
|
||||||
|
recording, retention, and outbox guidance remains in the separate admin
|
||||||
|
topic.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run the Audit backend suite and `npm run test:interface-patterns` in `webui`.
|
||||||
|
The structural test guards shared components, localized labels, contextual
|
||||||
|
help, server paging, readable detail projection, and absence of private sibling
|
||||||
|
imports or browser-native dialogs.
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/audit-webui",
|
"name": "@govoplan/audit-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+5
-2
@@ -4,15 +4,18 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-audit"
|
name = "govoplan-audit"
|
||||||
version = "0.1.8"
|
version = "0.1.19"
|
||||||
description = "GovOPlaN audit platform module."
|
description = "GovOPlaN audit platform module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
govoplan-audit-verify = "govoplan_audit.backend.verify_evidence_bundle:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, false, func, or_
|
from sqlalchemy import and_, false, func, or_
|
||||||
@@ -8,7 +10,12 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope
|
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope
|
||||||
from govoplan_audit.backend.db.models import AuditLog
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
from govoplan_core.audit.logging import AUDIT_MODULE_ID, AUDIT_SYSTEM_EVENTS_COLLECTION, AUDIT_TENANT_EVENTS_COLLECTION
|
from govoplan_core.audit.logging import (
|
||||||
|
AUDIT_MODULE_ID,
|
||||||
|
AUDIT_SYSTEM_EVENTS_COLLECTION,
|
||||||
|
AUDIT_TENANT_EVENTS_COLLECTION,
|
||||||
|
audit_from_principal,
|
||||||
|
)
|
||||||
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
|
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
|
||||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
|
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
|
||||||
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
|
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
|
||||||
@@ -16,13 +23,53 @@ from govoplan_core.core.runtime import get_registry
|
|||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_core.tenancy.scope import Tenant
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
from .schemas import AuditAdminDeltaResponse, AuditAdminItem, AuditAdminListResponse, AuditLogItemResponse, AuditLogListResponse
|
from govoplan_core.core.events import platform_event_outbox
|
||||||
|
from govoplan_audit.backend.db.models import AuditEvidenceBundle
|
||||||
|
from govoplan_audit.backend.evidence_bundles import (
|
||||||
|
EvidenceBundleError,
|
||||||
|
build_evidence_bundle,
|
||||||
|
canonical_sha256,
|
||||||
|
configured_signing_key,
|
||||||
|
normalize_evidence_reference,
|
||||||
|
)
|
||||||
|
from govoplan_audit.backend.permissions import (
|
||||||
|
AUDIT_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
AuditAdminDeltaResponse,
|
||||||
|
AuditAdminItem,
|
||||||
|
AuditAdminListResponse,
|
||||||
|
AuditLogItemResponse,
|
||||||
|
AuditLogListResponse,
|
||||||
|
EventDeliveryMetricsResponse,
|
||||||
|
EventDeliveryReplayRequest,
|
||||||
|
EventDeliveryReplayResponse,
|
||||||
|
EvidenceBundleDownloadResponse,
|
||||||
|
EvidenceBundleExportRequest,
|
||||||
|
EvidenceBundleResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["audit"])
|
router = APIRouter(tags=["audit"])
|
||||||
|
|
||||||
AUDIT_ADMIN_CURSOR_SCOPE = "audit.admin"
|
AUDIT_ADMIN_CURSOR_SCOPE = "audit.admin"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuditAdminQueryContext:
|
||||||
|
query: Any
|
||||||
|
access_admin: AccessAdministration
|
||||||
|
effective_scope: str
|
||||||
|
resolved_tenant_id: str | None
|
||||||
|
sort_column: Any
|
||||||
|
order: Any
|
||||||
|
total: int
|
||||||
|
effective_page_size: int
|
||||||
|
pages: int
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
|
||||||
def _access_administration() -> AccessAdministration:
|
def _access_administration() -> AccessAdministration:
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
|
||||||
@@ -180,6 +227,36 @@ def _audit_delta_response_watermark(
|
|||||||
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=tenant_id)
|
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_audit_delta_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
context: AuditAdminQueryContext,
|
||||||
|
page_query: Any,
|
||||||
|
start_cursor: str | None,
|
||||||
|
sort_by: str,
|
||||||
|
sort_direction: str,
|
||||||
|
) -> AuditAdminDeltaResponse:
|
||||||
|
rows_plus_one = page_query.order_by(context.order, AuditLog.id.desc()).limit(context.effective_page_size + 1).all()
|
||||||
|
rows = rows_plus_one[:context.effective_page_size]
|
||||||
|
next_cursor = (
|
||||||
|
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
|
||||||
|
if len(rows_plus_one) > context.effective_page_size and rows else None
|
||||||
|
)
|
||||||
|
return AuditAdminDeltaResponse(
|
||||||
|
total=context.total,
|
||||||
|
page=1,
|
||||||
|
page_size=context.effective_page_size,
|
||||||
|
pages=context.pages,
|
||||||
|
cursor=start_cursor,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
items=_audit_items(session, rows, context.access_admin),
|
||||||
|
deleted=[],
|
||||||
|
watermark=_audit_delta_watermark(session, effective_scope=context.effective_scope, tenant_id=context.resolved_tenant_id),
|
||||||
|
has_more=False,
|
||||||
|
full=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _audit_items(session: Session, rows: list[AuditLog], access_admin: AccessAdministration) -> list[AuditAdminItem]:
|
def _audit_items(session: Session, rows: list[AuditLog], access_admin: AccessAdministration) -> list[AuditAdminItem]:
|
||||||
actor_email_by_user_id = access_admin.actor_email_by_user_id(session, {row.user_id for row in rows if row.user_id})
|
actor_email_by_user_id = access_admin.actor_email_by_user_id(session, {row.user_id for row in rows if row.user_id})
|
||||||
return [
|
return [
|
||||||
@@ -286,26 +363,23 @@ def _audit_cursor_condition(sort_column, *, sort_by: str, sort_direction: str, c
|
|||||||
return or_(primary_after, and_(sort_column == sort_value, AuditLog.id < cursor_id))
|
return or_(primary_after, and_(sort_column == sort_value, AuditLog.id < cursor_id))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/audit", response_model=AuditAdminListResponse)
|
def _prepare_audit_admin_query(
|
||||||
def list_admin_audit(
|
session: Session,
|
||||||
tenant_id: str | None = Query(default=None),
|
principal: ApiPrincipal,
|
||||||
all_tenants: bool = Query(default=False),
|
*,
|
||||||
audit_scope: str | None = Query(default=None, alias="scope"),
|
tenant_id: str | None,
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
all_tenants: bool,
|
||||||
offset: int = Query(default=0, ge=0),
|
audit_scope: str | None,
|
||||||
page: int | None = Query(default=None, ge=1),
|
limit: int,
|
||||||
page_size: int | None = Query(default=None, ge=1, le=500),
|
page_size: int | None,
|
||||||
cursor: str | None = Query(default=None),
|
sort_by: str,
|
||||||
sort_by: str = Query(default="time"),
|
sort_direction: str,
|
||||||
sort_direction: str = Query(default="desc"),
|
filter_time: str | None,
|
||||||
filter_time: str | None = Query(default=None),
|
filter_actor: str | None,
|
||||||
filter_actor: str | None = Query(default=None),
|
filter_action: str | None,
|
||||||
filter_action: str | None = Query(default=None),
|
filter_object: str | None,
|
||||||
filter_object: str | None = Query(default=None),
|
filter_tenant: str | None,
|
||||||
filter_tenant: str | None = Query(default=None),
|
) -> AuditAdminQueryContext:
|
||||||
session: Session = Depends(get_session),
|
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
|
|
||||||
):
|
|
||||||
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
|
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
|
||||||
if effective_scope not in {"tenant", "system", "all"}:
|
if effective_scope not in {"tenant", "system", "all"}:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.")
|
||||||
@@ -333,14 +407,13 @@ def list_admin_audit(
|
|||||||
|
|
||||||
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
|
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
|
||||||
access_admin = _access_administration()
|
access_admin = _access_administration()
|
||||||
filters = [
|
for condition in (
|
||||||
_audit_time_filter(filter_time),
|
_audit_time_filter(filter_time),
|
||||||
_audit_actor_filter(access_admin, session, filter_actor),
|
_audit_actor_filter(access_admin, session, filter_actor),
|
||||||
_audit_text_filter(AuditLog.action, filter_action),
|
_audit_text_filter(AuditLog.action, filter_action),
|
||||||
_audit_text_filter(object_text, filter_object),
|
_audit_text_filter(object_text, filter_object),
|
||||||
_audit_text_filter(AuditLog.tenant_id, filter_tenant),
|
_audit_text_filter(AuditLog.tenant_id, filter_tenant),
|
||||||
]
|
):
|
||||||
for condition in filters:
|
|
||||||
if condition is not None:
|
if condition is not None:
|
||||||
query = query.filter(condition)
|
query = query.filter(condition)
|
||||||
|
|
||||||
@@ -353,7 +426,6 @@ def list_admin_audit(
|
|||||||
}
|
}
|
||||||
sort_column = sort_columns[sort_by]
|
sort_column = sort_columns[sort_by]
|
||||||
order = sort_column.asc() if sort_direction == "asc" else sort_column.desc()
|
order = sort_column.asc() if sort_direction == "asc" else sort_column.desc()
|
||||||
ordered_query = query.order_by(order, AuditLog.id.desc())
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
effective_page_size = page_size or limit
|
effective_page_size = page_size or limit
|
||||||
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
|
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
|
||||||
@@ -372,47 +444,100 @@ def list_admin_audit(
|
|||||||
sort_direction=sort_direction,
|
sort_direction=sort_direction,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
)
|
)
|
||||||
|
return AuditAdminQueryContext(
|
||||||
|
query=query,
|
||||||
|
access_admin=access_admin,
|
||||||
|
effective_scope=effective_scope,
|
||||||
|
resolved_tenant_id=resolved_tenant_id,
|
||||||
|
sort_column=sort_column,
|
||||||
|
order=order,
|
||||||
|
total=total,
|
||||||
|
effective_page_size=effective_page_size,
|
||||||
|
pages=pages,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/audit", response_model=AuditAdminListResponse)
|
||||||
|
def list_admin_audit(
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
all_tenants: bool = Query(default=False),
|
||||||
|
audit_scope: str | None = Query(default=None, alias="scope"),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int | None = Query(default=None, ge=1, le=500),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
sort_by: str = Query(default="time"),
|
||||||
|
sort_direction: str = Query(default="desc"),
|
||||||
|
filter_time: str | None = Query(default=None),
|
||||||
|
filter_actor: str | None = Query(default=None),
|
||||||
|
filter_action: str | None = Query(default=None),
|
||||||
|
filter_object: str | None = Query(default=None),
|
||||||
|
filter_tenant: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
|
||||||
|
):
|
||||||
|
context = _prepare_audit_admin_query(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
all_tenants=all_tenants,
|
||||||
|
audit_scope=audit_scope,
|
||||||
|
limit=limit,
|
||||||
|
page_size=page_size,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_direction=sort_direction,
|
||||||
|
filter_time=filter_time,
|
||||||
|
filter_actor=filter_actor,
|
||||||
|
filter_action=filter_action,
|
||||||
|
filter_object=filter_object,
|
||||||
|
filter_tenant=filter_tenant,
|
||||||
|
)
|
||||||
|
ordered_query = context.query.order_by(context.order, AuditLog.id.desc())
|
||||||
|
|
||||||
start_cursor: str | None = None
|
start_cursor: str | None = None
|
||||||
if cursor:
|
if cursor:
|
||||||
try:
|
try:
|
||||||
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
|
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=context.fingerprint)
|
||||||
if cursor_values is None:
|
if cursor_values is None:
|
||||||
raise KeysetCursorError("Invalid pagination cursor")
|
raise KeysetCursorError("Invalid pagination cursor")
|
||||||
page_query = query.filter(_audit_cursor_condition(sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values))
|
page_query = context.query.filter(
|
||||||
|
_audit_cursor_condition(context.sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values)
|
||||||
|
)
|
||||||
except KeysetCursorError as exc:
|
except KeysetCursorError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
effective_page = page or (offset // effective_page_size + 1)
|
effective_page = page or (offset // context.effective_page_size + 1)
|
||||||
effective_offset = 0
|
effective_offset = 0
|
||||||
start_cursor = cursor
|
start_cursor = cursor
|
||||||
else:
|
else:
|
||||||
if page is not None or page_size is not None:
|
if page is not None or page_size is not None:
|
||||||
effective_page = min(page or 1, pages)
|
effective_page = min(page or 1, context.pages)
|
||||||
effective_offset = (effective_page - 1) * effective_page_size
|
effective_offset = (effective_page - 1) * context.effective_page_size
|
||||||
else:
|
else:
|
||||||
effective_page = offset // effective_page_size + 1
|
effective_page = offset // context.effective_page_size + 1
|
||||||
effective_offset = offset
|
effective_offset = offset
|
||||||
page_query = query
|
page_query = context.query
|
||||||
if effective_offset > 0:
|
if effective_offset > 0:
|
||||||
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
|
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
|
||||||
if previous_row is not None:
|
if previous_row is not None:
|
||||||
start_cursor = _audit_cursor_for_row(previous_row, sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
|
start_cursor = _audit_cursor_for_row(previous_row, sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
|
||||||
|
|
||||||
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).offset(effective_offset).limit(effective_page_size + 1).all()
|
rows_plus_one = page_query.order_by(context.order, AuditLog.id.desc()).offset(effective_offset).limit(context.effective_page_size + 1).all()
|
||||||
rows = rows_plus_one[:effective_page_size]
|
rows = rows_plus_one[:context.effective_page_size]
|
||||||
next_cursor = (
|
next_cursor = (
|
||||||
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
|
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=context.fingerprint)
|
||||||
if len(rows_plus_one) > effective_page_size and rows else None
|
if len(rows_plus_one) > context.effective_page_size and rows else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return AuditAdminListResponse(
|
return AuditAdminListResponse(
|
||||||
total=total,
|
total=context.total,
|
||||||
page=effective_page,
|
page=effective_page,
|
||||||
page_size=effective_page_size,
|
page_size=context.effective_page_size,
|
||||||
pages=pages,
|
pages=context.pages,
|
||||||
cursor=start_cursor,
|
cursor=start_cursor,
|
||||||
next_cursor=next_cursor,
|
next_cursor=next_cursor,
|
||||||
items=_audit_items(session, rows, access_admin),
|
items=_audit_items(session, rows, context.access_admin),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -435,150 +560,81 @@ def list_admin_audit_delta(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
|
||||||
):
|
):
|
||||||
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
|
context = _prepare_audit_admin_query(
|
||||||
if effective_scope not in {"tenant", "system", "all"}:
|
session,
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit scope must be tenant, system or all.")
|
principal,
|
||||||
if sort_by not in {"time", "actor", "action", "object", "tenant"}:
|
tenant_id=tenant_id,
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unsupported audit sort column.")
|
all_tenants=all_tenants,
|
||||||
if sort_direction not in {"asc", "desc"}:
|
audit_scope=audit_scope,
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit sort direction must be asc or desc.")
|
limit=limit,
|
||||||
|
page_size=page_size,
|
||||||
query = session.query(AuditLog)
|
sort_by=sort_by,
|
||||||
resolved_tenant_id: str | None = None
|
sort_direction=sort_direction,
|
||||||
if effective_scope != "all":
|
|
||||||
query = query.filter(AuditLog.scope == effective_scope)
|
|
||||||
if effective_scope == "system":
|
|
||||||
if not has_scope(principal, "system:audit:read"):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read")
|
|
||||||
elif effective_scope == "all" or all_tenants:
|
|
||||||
if not has_scope(principal, "system:audit:read"):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:audit:read")
|
|
||||||
else:
|
|
||||||
if not has_scope(principal, "audit:read"):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: audit:read")
|
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
|
||||||
resolved_tenant_id = tenant.id
|
|
||||||
query = query.filter(AuditLog.tenant_id == tenant.id)
|
|
||||||
|
|
||||||
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
|
|
||||||
access_admin = _access_administration()
|
|
||||||
filters = [
|
|
||||||
_audit_time_filter(filter_time),
|
|
||||||
_audit_actor_filter(access_admin, session, filter_actor),
|
|
||||||
_audit_text_filter(AuditLog.action, filter_action),
|
|
||||||
_audit_text_filter(object_text, filter_object),
|
|
||||||
_audit_text_filter(AuditLog.tenant_id, filter_tenant),
|
|
||||||
]
|
|
||||||
for condition in filters:
|
|
||||||
if condition is not None:
|
|
||||||
query = query.filter(condition)
|
|
||||||
|
|
||||||
sort_columns = {
|
|
||||||
"time": AuditLog.created_at,
|
|
||||||
"actor": func.coalesce(AuditLog.user_id, "System"),
|
|
||||||
"action": AuditLog.action,
|
|
||||||
"object": object_text,
|
|
||||||
"tenant": func.coalesce(AuditLog.tenant_id, ""),
|
|
||||||
}
|
|
||||||
sort_column = sort_columns[sort_by]
|
|
||||||
order = sort_column.asc() if sort_direction == "asc" else sort_column.desc()
|
|
||||||
total = query.count()
|
|
||||||
effective_page_size = page_size or limit
|
|
||||||
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
|
|
||||||
filters = _audit_filter_params(
|
|
||||||
filter_time=filter_time,
|
filter_time=filter_time,
|
||||||
filter_actor=filter_actor,
|
filter_actor=filter_actor,
|
||||||
filter_action=filter_action,
|
filter_action=filter_action,
|
||||||
filter_object=filter_object,
|
filter_object=filter_object,
|
||||||
filter_tenant=filter_tenant,
|
filter_tenant=filter_tenant,
|
||||||
)
|
)
|
||||||
fingerprint = _audit_cursor_fingerprint(
|
|
||||||
effective_scope=effective_scope,
|
|
||||||
tenant_id=resolved_tenant_id,
|
|
||||||
page_size=effective_page_size,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_direction=sort_direction,
|
|
||||||
filters=filters,
|
|
||||||
)
|
|
||||||
start_cursor: str | None = None
|
start_cursor: str | None = None
|
||||||
page_query = query
|
page_query = context.query
|
||||||
if cursor:
|
if cursor:
|
||||||
try:
|
try:
|
||||||
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
|
cursor_values = decode_keyset_cursor(AUDIT_ADMIN_CURSOR_SCOPE, cursor, fingerprint=context.fingerprint)
|
||||||
if cursor_values is None:
|
if cursor_values is None:
|
||||||
raise KeysetCursorError("Invalid pagination cursor")
|
raise KeysetCursorError("Invalid pagination cursor")
|
||||||
page_query = query.filter(_audit_cursor_condition(sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values))
|
page_query = context.query.filter(
|
||||||
|
_audit_cursor_condition(context.sort_column, sort_by=sort_by, sort_direction=sort_direction, cursor_values=cursor_values)
|
||||||
|
)
|
||||||
start_cursor = cursor
|
start_cursor = cursor
|
||||||
except KeysetCursorError as exc:
|
except KeysetCursorError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
if since is None:
|
if since is None:
|
||||||
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all()
|
return _full_audit_delta_response(
|
||||||
rows = rows_plus_one[:effective_page_size]
|
session,
|
||||||
next_cursor = (
|
context=context,
|
||||||
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
|
page_query=page_query,
|
||||||
if len(rows_plus_one) > effective_page_size and rows else None
|
start_cursor=start_cursor,
|
||||||
)
|
sort_by=sort_by,
|
||||||
return AuditAdminDeltaResponse(
|
sort_direction=sort_direction,
|
||||||
total=total,
|
|
||||||
page=1,
|
|
||||||
page_size=effective_page_size,
|
|
||||||
pages=pages,
|
|
||||||
cursor=start_cursor,
|
|
||||||
next_cursor=next_cursor,
|
|
||||||
items=_audit_items(session, rows, access_admin),
|
|
||||||
deleted=[],
|
|
||||||
watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id),
|
|
||||||
has_more=False,
|
|
||||||
full=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
entries, has_more = _audit_delta_entries(
|
entries, has_more = _audit_delta_entries(
|
||||||
session,
|
session,
|
||||||
effective_scope=effective_scope,
|
effective_scope=context.effective_scope,
|
||||||
tenant_id=resolved_tenant_id,
|
tenant_id=context.resolved_tenant_id,
|
||||||
since=since,
|
since=since,
|
||||||
limit=effective_page_size,
|
limit=context.effective_page_size,
|
||||||
)
|
)
|
||||||
if entries is None:
|
if entries is None:
|
||||||
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all()
|
return _full_audit_delta_response(
|
||||||
rows = rows_plus_one[:effective_page_size]
|
session,
|
||||||
next_cursor = (
|
context=context,
|
||||||
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
|
page_query=page_query,
|
||||||
if len(rows_plus_one) > effective_page_size and rows else None
|
start_cursor=start_cursor,
|
||||||
)
|
sort_by=sort_by,
|
||||||
return AuditAdminDeltaResponse(
|
sort_direction=sort_direction,
|
||||||
total=total,
|
|
||||||
page=1,
|
|
||||||
page_size=effective_page_size,
|
|
||||||
pages=pages,
|
|
||||||
cursor=start_cursor,
|
|
||||||
next_cursor=next_cursor,
|
|
||||||
items=_audit_items(session, rows, access_admin),
|
|
||||||
deleted=[],
|
|
||||||
watermark=_audit_delta_watermark(session, effective_scope=effective_scope, tenant_id=resolved_tenant_id),
|
|
||||||
has_more=False,
|
|
||||||
full=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
changed_ids = [entry.resource_id for entry in entries if entry.resource_type == "audit_log"]
|
changed_ids = [entry.resource_id for entry in entries if entry.resource_type == "audit_log"]
|
||||||
rows = (
|
rows = (
|
||||||
page_query.filter(AuditLog.id.in_(changed_ids)).order_by(order, AuditLog.id.desc()).limit(effective_page_size).all()
|
page_query.filter(AuditLog.id.in_(changed_ids)).order_by(context.order, AuditLog.id.desc()).limit(context.effective_page_size).all()
|
||||||
if changed_ids else []
|
if changed_ids else []
|
||||||
)
|
)
|
||||||
return AuditAdminDeltaResponse(
|
return AuditAdminDeltaResponse(
|
||||||
total=total,
|
total=context.total,
|
||||||
page=1,
|
page=1,
|
||||||
page_size=effective_page_size,
|
page_size=context.effective_page_size,
|
||||||
pages=pages,
|
pages=context.pages,
|
||||||
cursor=start_cursor,
|
cursor=start_cursor,
|
||||||
next_cursor=None,
|
next_cursor=None,
|
||||||
items=_audit_items(session, rows, access_admin),
|
items=_audit_items(session, rows, context.access_admin),
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=_audit_delta_response_watermark(
|
watermark=_audit_delta_response_watermark(
|
||||||
session,
|
session,
|
||||||
effective_scope=effective_scope,
|
effective_scope=context.effective_scope,
|
||||||
tenant_id=resolved_tenant_id,
|
tenant_id=context.resolved_tenant_id,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
),
|
),
|
||||||
@@ -608,3 +664,364 @@ def list_audit_log(
|
|||||||
query = query.filter(AuditLog.object_id == object_id)
|
query = query.filter(AuditLog.object_id == object_id)
|
||||||
items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items])
|
return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/audit/event-delivery/metrics",
|
||||||
|
response_model=EventDeliveryMetricsResponse,
|
||||||
|
)
|
||||||
|
def event_delivery_metrics(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("system:audit:read")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
outbox = platform_event_outbox(get_registry())
|
||||||
|
if outbox is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Durable platform event delivery is not configured",
|
||||||
|
)
|
||||||
|
return EventDeliveryMetricsResponse.model_validate(
|
||||||
|
outbox.delivery_metrics(session)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/audit/event-deliveries/{event_id}/{consumer_id}/replay",
|
||||||
|
response_model=EventDeliveryReplayResponse,
|
||||||
|
)
|
||||||
|
def replay_event_delivery(
|
||||||
|
event_id: str,
|
||||||
|
consumer_id: str,
|
||||||
|
payload: EventDeliveryReplayRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("system:governance:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
outbox = platform_event_outbox(get_registry())
|
||||||
|
if outbox is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Durable platform event delivery is not configured",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = outbox.replay_delivery(
|
||||||
|
session,
|
||||||
|
event_id=event_id,
|
||||||
|
consumer_id=consumer_id,
|
||||||
|
operator_id=principal.account_id,
|
||||||
|
reason=payload.reason,
|
||||||
|
)
|
||||||
|
except LookupError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="platform_event.delivery_replayed",
|
||||||
|
scope="system",
|
||||||
|
object_type="platform_event_delivery",
|
||||||
|
object_id=f"{event_id}:{consumer_id}",
|
||||||
|
details={
|
||||||
|
"event_id": event_id,
|
||||||
|
"consumer_id": consumer_id,
|
||||||
|
"reason": payload.reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return EventDeliveryReplayResponse.model_validate(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/audit/evidence-bundles",
|
||||||
|
response_model=EvidenceBundleResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def export_evidence_bundle(
|
||||||
|
payload: EvidenceBundleExportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
AUDIT_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
scope, tenant_id = _resolve_evidence_bundle_scope(session, principal, payload)
|
||||||
|
records = _evidence_bundle_records(
|
||||||
|
session,
|
||||||
|
payload=payload,
|
||||||
|
scope=scope,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
normalized_references = [
|
||||||
|
normalize_evidence_reference(item.model_dump(mode="json"))
|
||||||
|
for item in payload.references
|
||||||
|
]
|
||||||
|
except EvidenceBundleError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
request_payload = payload.model_dump(mode="json")
|
||||||
|
request_payload["resolved_tenant_id"] = tenant_id
|
||||||
|
request_payload["selection_complete"] = True
|
||||||
|
row = AuditEvidenceBundle(
|
||||||
|
scope=scope,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
requested_by=principal.account_id,
|
||||||
|
status="pending",
|
||||||
|
request_payload=request_payload,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
generated_at = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
key_id, key_path = (
|
||||||
|
configured_signing_key(required=True)
|
||||||
|
if payload.sign
|
||||||
|
else (None, None)
|
||||||
|
)
|
||||||
|
bundle = build_evidence_bundle(
|
||||||
|
records,
|
||||||
|
bundle_id=row.id,
|
||||||
|
generated_at=generated_at,
|
||||||
|
scope={"kind": scope, "tenant_id": tenant_id},
|
||||||
|
request=_evidence_manifest_request(request_payload),
|
||||||
|
references=normalized_references,
|
||||||
|
signing_key_id=key_id if payload.sign else None,
|
||||||
|
signing_private_key_path=key_path if payload.sign else None,
|
||||||
|
)
|
||||||
|
except EvidenceBundleError as exc:
|
||||||
|
row.status = "failed"
|
||||||
|
row.error_code = "evidence_bundle_generation_failed"
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
row.status = "ready"
|
||||||
|
row.bundle_payload = bundle
|
||||||
|
row.bundle_sha256 = canonical_sha256(bundle)
|
||||||
|
row.record_count = len(records)
|
||||||
|
row.reference_count = len(payload.references)
|
||||||
|
row.generated_at = generated_at
|
||||||
|
session.add(row)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="audit.evidence_bundle.generated",
|
||||||
|
scope="system" if scope in {"system", "all"} else "tenant",
|
||||||
|
object_type="audit_evidence_bundle",
|
||||||
|
object_id=row.id,
|
||||||
|
details={
|
||||||
|
"bundle_sha256": row.bundle_sha256,
|
||||||
|
"record_count": row.record_count,
|
||||||
|
"reference_count": row.reference_count,
|
||||||
|
"scope": scope,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _evidence_bundle_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/audit/evidence-bundles/{bundle_id}",
|
||||||
|
response_model=EvidenceBundleResponse,
|
||||||
|
)
|
||||||
|
def get_evidence_bundle(
|
||||||
|
bundle_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
AUDIT_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
row = _authorized_evidence_bundle(session, principal, bundle_id)
|
||||||
|
return _evidence_bundle_response(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/admin/audit/evidence-bundles/{bundle_id}/download",
|
||||||
|
response_model=EvidenceBundleDownloadResponse,
|
||||||
|
)
|
||||||
|
def download_evidence_bundle(
|
||||||
|
bundle_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
AUDIT_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
row = _authorized_evidence_bundle(session, principal, bundle_id)
|
||||||
|
if row.status != "ready" or not isinstance(row.bundle_payload, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Evidence bundle is not ready for download.",
|
||||||
|
)
|
||||||
|
if not row.bundle_sha256 or canonical_sha256(row.bundle_payload) != row.bundle_sha256:
|
||||||
|
row.status = "failed"
|
||||||
|
row.error_code = "evidence_bundle_storage_integrity_failed"
|
||||||
|
session.add(row)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="audit.evidence_bundle.integrity_failed",
|
||||||
|
scope="system" if row.scope in {"system", "all"} else "tenant",
|
||||||
|
object_type="audit_evidence_bundle",
|
||||||
|
object_id=row.id,
|
||||||
|
details={"expected_bundle_sha256": row.bundle_sha256},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Stored evidence bundle failed its canonical integrity check.",
|
||||||
|
)
|
||||||
|
row.downloaded_at = datetime.now(timezone.utc)
|
||||||
|
session.add(row)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="audit.evidence_bundle.downloaded",
|
||||||
|
scope="system" if row.scope in {"system", "all"} else "tenant",
|
||||||
|
object_type="audit_evidence_bundle",
|
||||||
|
object_id=row.id,
|
||||||
|
details={"bundle_sha256": row.bundle_sha256},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return EvidenceBundleDownloadResponse(bundle=row.bundle_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_evidence_bundle_scope(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
payload: EvidenceBundleExportRequest,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
if payload.scope in {"system", "all"}:
|
||||||
|
if not has_scope(principal, AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing scope: {AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE}",
|
||||||
|
)
|
||||||
|
if payload.tenant_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="System and all-scope evidence bundles do not accept a tenant id.",
|
||||||
|
)
|
||||||
|
return payload.scope, None
|
||||||
|
if not has_scope(principal, AUDIT_EVIDENCE_EXPORT_SCOPE):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing scope: {AUDIT_EVIDENCE_EXPORT_SCOPE}",
|
||||||
|
)
|
||||||
|
tenant = _resolve_tenant(session, principal, payload.tenant_id)
|
||||||
|
return "tenant", tenant.id
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_bundle_records(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
payload: EvidenceBundleExportRequest,
|
||||||
|
scope: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
) -> list[AuditLog]:
|
||||||
|
query = session.query(AuditLog)
|
||||||
|
if scope == "tenant":
|
||||||
|
query = query.filter(AuditLog.scope == "tenant", AuditLog.tenant_id == tenant_id)
|
||||||
|
elif scope == "system":
|
||||||
|
query = query.filter(AuditLog.scope == "system")
|
||||||
|
if payload.since is not None:
|
||||||
|
query = query.filter(AuditLog.created_at >= payload.since)
|
||||||
|
if payload.until is not None:
|
||||||
|
query = query.filter(AuditLog.created_at <= payload.until)
|
||||||
|
if payload.record_ids:
|
||||||
|
query = query.filter(AuditLog.id.in_(payload.record_ids))
|
||||||
|
if payload.action:
|
||||||
|
query = query.filter(AuditLog.action == payload.action)
|
||||||
|
if payload.object_type:
|
||||||
|
query = query.filter(AuditLog.object_type == payload.object_type)
|
||||||
|
if payload.object_id:
|
||||||
|
query = query.filter(AuditLog.object_id == payload.object_id)
|
||||||
|
records = (
|
||||||
|
query.order_by(AuditLog.created_at.asc(), AuditLog.id.asc())
|
||||||
|
.limit(payload.max_records + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(records) > payload.max_records:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Evidence selection exceeds the bounded record limit; narrow the requested scope.",
|
||||||
|
)
|
||||||
|
if payload.record_ids and {item.id for item in records} != set(payload.record_ids):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="One or more requested audit records are unavailable in the authorized scope.",
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _authorized_evidence_bundle(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
bundle_id: str,
|
||||||
|
) -> AuditEvidenceBundle:
|
||||||
|
row = session.get(AuditEvidenceBundle, bundle_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Evidence bundle not found.")
|
||||||
|
if row.scope in {"system", "all"}:
|
||||||
|
allowed = has_scope(principal, AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE)
|
||||||
|
else:
|
||||||
|
allowed = (
|
||||||
|
has_scope(principal, AUDIT_EVIDENCE_EXPORT_SCOPE)
|
||||||
|
and row.tenant_id == principal.tenant_id
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Evidence bundle not found.")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_manifest_request(request_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = dict(request_payload)
|
||||||
|
references = result.pop("references", [])
|
||||||
|
result["reference_ids"] = [
|
||||||
|
item.get("reference_id")
|
||||||
|
for item in references
|
||||||
|
if isinstance(item, dict) and item.get("reference_id")
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_bundle_response(row: AuditEvidenceBundle) -> EvidenceBundleResponse:
|
||||||
|
return EvidenceBundleResponse(
|
||||||
|
id=row.id,
|
||||||
|
scope=row.scope,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
status=row.status,
|
||||||
|
bundle_sha256=row.bundle_sha256,
|
||||||
|
record_count=row.record_count,
|
||||||
|
reference_count=row.reference_count,
|
||||||
|
generated_at=row.generated_at,
|
||||||
|
downloaded_at=row.downloaded_at,
|
||||||
|
error_code=row.error_code,
|
||||||
|
created_at=row.created_at,
|
||||||
|
download_url=(
|
||||||
|
f"/api/v1/admin/audit/evidence-bundles/{row.id}/download"
|
||||||
|
if row.status == "ready"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
|
||||||
@@ -53,3 +53,92 @@ class AuditLogItemResponse(BaseModel):
|
|||||||
|
|
||||||
class AuditLogListResponse(BaseModel):
|
class AuditLogListResponse(BaseModel):
|
||||||
items: list[AuditLogItemResponse]
|
items: list[AuditLogItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class EventDeliveryMetricsResponse(BaseModel):
|
||||||
|
events: dict[str, int] = Field(default_factory=dict)
|
||||||
|
deliveries: dict[str, int] = Field(default_factory=dict)
|
||||||
|
consumers: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||||
|
oldest_due_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventDeliveryReplayRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
reason: str = Field(min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class EventDeliveryReplayResponse(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
consumer_id: str
|
||||||
|
delivery_key: str
|
||||||
|
status: str
|
||||||
|
attempts: int
|
||||||
|
replay_count: int
|
||||||
|
last_replayed_at: datetime | None = None
|
||||||
|
last_replayed_by: str | None = None
|
||||||
|
last_replay_reason: str | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceBundleReferenceRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
reference_id: str = Field(min_length=1, max_length=200)
|
||||||
|
kind: str = Field(min_length=1, max_length=80)
|
||||||
|
owner_module: str = Field(min_length=1, max_length=100)
|
||||||
|
locator: str = Field(min_length=1, max_length=2048)
|
||||||
|
content_sha256: str | None = Field(default=None, pattern=r"^[0-9A-Fa-f]{64}$")
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceBundleExportRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
scope: Literal["tenant", "system", "all"] = "tenant"
|
||||||
|
tenant_id: str | None = Field(default=None, max_length=36)
|
||||||
|
since: datetime | None = None
|
||||||
|
until: datetime | None = None
|
||||||
|
record_ids: list[str] = Field(default_factory=list, max_length=500)
|
||||||
|
action: str | None = Field(default=None, max_length=100)
|
||||||
|
object_type: str | None = Field(default=None, max_length=100)
|
||||||
|
object_id: str | None = Field(default=None, max_length=100)
|
||||||
|
max_records: int = Field(default=500, ge=1, le=500)
|
||||||
|
references: list[EvidenceBundleReferenceRequest] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
sign: bool = False
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_window_and_selection(self):
|
||||||
|
for label, value in (("since", self.since), ("until", self.until)):
|
||||||
|
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
||||||
|
raise ValueError(f"Evidence bundle {label} must include a timezone.")
|
||||||
|
if self.since and self.until and self.until < self.since:
|
||||||
|
raise ValueError("Evidence bundle until must not precede since.")
|
||||||
|
if len(self.record_ids) != len(set(self.record_ids)):
|
||||||
|
raise ValueError("Evidence bundle record ids must be unique.")
|
||||||
|
reference_ids = [item.reference_id for item in self.references]
|
||||||
|
if len(reference_ids) != len(set(reference_ids)):
|
||||||
|
raise ValueError("Evidence bundle reference ids must be unique.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceBundleResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
scope: Literal["tenant", "system", "all"]
|
||||||
|
tenant_id: str | None = None
|
||||||
|
status: Literal["pending", "ready", "failed"]
|
||||||
|
bundle_sha256: str | None = None
|
||||||
|
record_count: int
|
||||||
|
reference_count: int
|
||||||
|
generated_at: datetime | None = None
|
||||||
|
downloaded_at: datetime | None = None
|
||||||
|
error_code: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
download_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceBundleDownloadResponse(BaseModel):
|
||||||
|
bundle: dict[str, Any]
|
||||||
|
|||||||
@@ -57,4 +57,132 @@ class AuditOutboxEvent(Base, TimestampMixin):
|
|||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["AuditLog", "AuditOutboxEvent", "new_uuid"]
|
class AuditOutboxDelivery(Base, TimestampMixin):
|
||||||
|
__tablename__ = "audit_outbox_deliveries"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"outbox_event_id",
|
||||||
|
"consumer_id",
|
||||||
|
name="uq_audit_outbox_delivery_consumer",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"delivery_key",
|
||||||
|
name="uq_audit_outbox_delivery_key",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_audit_outbox_delivery_status_next_attempt_at",
|
||||||
|
"status",
|
||||||
|
"next_attempt_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_audit_outbox_delivery_consumer_status",
|
||||||
|
"consumer_id",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
outbox_event_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("audit_outbox_events.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
consumer_id: Mapped[str] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
delivery_key: Mapped[str] = mapped_column(
|
||||||
|
String(300),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
policy_decision_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="pending",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
attempts: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
quarantined_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
replay_count: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
last_replayed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
last_replayed_by: Mapped[str | None] = mapped_column(
|
||||||
|
String(128),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
last_replay_reason: Mapped[str | None] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvidenceBundle(Base, TimestampMixin):
|
||||||
|
__tablename__ = "audit_evidence_bundles"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_audit_evidence_bundle_tenant_created_at",
|
||||||
|
"tenant_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_audit_evidence_bundle_status_created_at",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
scope: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant")
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
requested_by: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
|
||||||
|
request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
bundle_payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
bundle_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
downloaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
error_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditEvidenceBundle",
|
||||||
|
"AuditLog",
|
||||||
|
"AuditOutboxDelivery",
|
||||||
|
"AuditOutboxEvent",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db.models import (
|
||||||
|
AuditEvidenceBundle,
|
||||||
|
AuditLog,
|
||||||
|
AuditOutboxDelivery,
|
||||||
|
AuditOutboxEvent,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_DSAR_CAPABILITY = dsar_capability_name("audit")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
_TRACE_KEYS = (
|
||||||
|
"correlation_id",
|
||||||
|
"causation_id",
|
||||||
|
"request_id",
|
||||||
|
"run_id",
|
||||||
|
"trace_id",
|
||||||
|
)
|
||||||
|
_REFERENCE_KEYS = (
|
||||||
|
"evidence_ref",
|
||||||
|
"legal_basis_ref",
|
||||||
|
"policy_decision_ref",
|
||||||
|
"policy_ref",
|
||||||
|
"source_ref",
|
||||||
|
)
|
||||||
|
_RESOURCE_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"audit_actor_record",
|
||||||
|
"audit_event_actor_record",
|
||||||
|
"audit_replay_attribution",
|
||||||
|
"audit_evidence_bundle_attribution",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
actor_ids: tuple[str, ...]
|
||||||
|
log_id: str | None
|
||||||
|
event_id: str | None
|
||||||
|
delivery_id: str | None
|
||||||
|
bundle_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditDsarProvider:
|
||||||
|
provider_id = "audit"
|
||||||
|
module_id = "audit"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
if not (selectors.event_id or selectors.delivery_id or selectors.bundle_id):
|
||||||
|
logs = db.query(AuditLog).filter(
|
||||||
|
AuditLog.scope == "tenant",
|
||||||
|
AuditLog.tenant_id == tenant_id,
|
||||||
|
AuditLog.user_id.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
if selectors.log_id:
|
||||||
|
logs = logs.filter(AuditLog.id == selectors.log_id)
|
||||||
|
records.extend(
|
||||||
|
_log_record(row)
|
||||||
|
for row in _limited(logs, AuditLog, "actor audit records")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (selectors.log_id or selectors.delivery_id or selectors.bundle_id):
|
||||||
|
events = db.query(AuditOutboxEvent).filter(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"].as_string() == tenant_id,
|
||||||
|
AuditOutboxEvent.payload["actor"]["id"]
|
||||||
|
.as_string()
|
||||||
|
.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
if selectors.event_id:
|
||||||
|
events = events.filter(
|
||||||
|
(AuditOutboxEvent.id == selectors.event_id)
|
||||||
|
| (AuditOutboxEvent.event_id == selectors.event_id)
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_event_record(row)
|
||||||
|
for row in _limited(events, AuditOutboxEvent, "actor event records")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (selectors.log_id or selectors.event_id or selectors.bundle_id):
|
||||||
|
deliveries = (
|
||||||
|
db.query(AuditOutboxDelivery, AuditOutboxEvent)
|
||||||
|
.join(
|
||||||
|
AuditOutboxEvent,
|
||||||
|
AuditOutboxDelivery.outbox_event_id == AuditOutboxEvent.id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"].as_string() == tenant_id,
|
||||||
|
AuditOutboxDelivery.last_replayed_by.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if selectors.delivery_id:
|
||||||
|
deliveries = deliveries.filter(
|
||||||
|
AuditOutboxDelivery.id == selectors.delivery_id
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
deliveries.order_by(
|
||||||
|
AuditOutboxDelivery.created_at,
|
||||||
|
AuditOutboxDelivery.id,
|
||||||
|
)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Audit DSAR replay-attribution limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_delivery_record(delivery, event) for delivery, event in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (selectors.log_id or selectors.event_id or selectors.delivery_id):
|
||||||
|
bundles = db.query(AuditEvidenceBundle).filter(
|
||||||
|
AuditEvidenceBundle.scope == "tenant",
|
||||||
|
AuditEvidenceBundle.tenant_id == tenant_id,
|
||||||
|
AuditEvidenceBundle.requested_by.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
if selectors.bundle_id:
|
||||||
|
bundles = bundles.filter(AuditEvidenceBundle.id == selectors.bundle_id)
|
||||||
|
records.extend(
|
||||||
|
_bundle_record(row)
|
||||||
|
for row in _limited(
|
||||||
|
bundles,
|
||||||
|
AuditEvidenceBundle,
|
||||||
|
"evidence-bundle attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(records) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Audit DSAR combined result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
order = {
|
||||||
|
"audit_actor_record": 10,
|
||||||
|
"audit_event_actor_record": 20,
|
||||||
|
"audit_replay_attribution": 30,
|
||||||
|
"audit_evidence_bundle_attribution": 40,
|
||||||
|
}
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
records,
|
||||||
|
key=lambda item: (order[item.resource_type], item.resource_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Audit DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=f"audit:retain:{record.resource_type}:{record.resource_id}",
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=record.retention_reason
|
||||||
|
or "Audit evidence must remain immutable.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Audit DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable or action.kind != "retain":
|
||||||
|
raise ValueError("Audit DSAR publishes retain-only actions.")
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Immutable Audit evidence remains under the configured "
|
||||||
|
"retention and legal-hold policy."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
values = {
|
||||||
|
"account_id": _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("audit.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
),
|
||||||
|
"membership_id": _coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
references.get("audit.membership"),
|
||||||
|
references.get("tenancy.membership"),
|
||||||
|
),
|
||||||
|
"identity_id": _coalesce(
|
||||||
|
subject.identity_id,
|
||||||
|
references.get("audit.identity"),
|
||||||
|
references.get("identity.id"),
|
||||||
|
),
|
||||||
|
"user_id": _coalesce(
|
||||||
|
references.get("audit.user"),
|
||||||
|
references.get("access.user"),
|
||||||
|
references.get("idm.user"),
|
||||||
|
),
|
||||||
|
"log_id": _coalesce(
|
||||||
|
references.get("audit.log"),
|
||||||
|
references.get("audit.record"),
|
||||||
|
),
|
||||||
|
"event_id": _coalesce(
|
||||||
|
references.get("audit.event"),
|
||||||
|
references.get("audit.outbox_event"),
|
||||||
|
),
|
||||||
|
"delivery_id": _coalesce(
|
||||||
|
references.get("audit.delivery"),
|
||||||
|
references.get("audit.outbox_delivery"),
|
||||||
|
),
|
||||||
|
"bundle_id": _coalesce(
|
||||||
|
references.get("audit.evidence_bundle"),
|
||||||
|
references.get("audit.bundle"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(value is _CONFLICT for value in values.values()):
|
||||||
|
return None
|
||||||
|
actor_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
value
|
||||||
|
for key in ("account_id", "membership_id", "identity_id", "user_id")
|
||||||
|
if (value := _optional_string(values[key]))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not actor_ids:
|
||||||
|
return None
|
||||||
|
return _SubjectSelectors(
|
||||||
|
actor_ids=actor_ids,
|
||||||
|
log_id=_optional_string(values["log_id"]),
|
||||||
|
event_id=_optional_string(values["event_id"]),
|
||||||
|
delivery_id=_optional_string(values["delivery_id"]),
|
||||||
|
bundle_id=_optional_string(values["bundle_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _limited(query, model, label: str):
|
||||||
|
rows = query.order_by(model.created_at, model.id).limit(_MAX_RECORDS + 1).all()
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(f"Audit DSAR {label} limit exceeded; narrow the selectors.")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _log_record(row: AuditLog) -> DsarRecordRef:
|
||||||
|
details = row.details if isinstance(row.details, Mapping) else {}
|
||||||
|
return _record(
|
||||||
|
"audit_actor_record",
|
||||||
|
row.id,
|
||||||
|
f"Audit action: {row.action[:100]}",
|
||||||
|
{
|
||||||
|
"scope": row.scope,
|
||||||
|
"actor_user_id": row.user_id,
|
||||||
|
"action": row.action,
|
||||||
|
"object_type": row.object_type,
|
||||||
|
"object_id": row.object_id,
|
||||||
|
"trace_context": _selected_context(details, _TRACE_KEYS),
|
||||||
|
"policy_and_source_references": _selected_context(
|
||||||
|
details,
|
||||||
|
_REFERENCE_KEYS,
|
||||||
|
),
|
||||||
|
"recorded_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.created_at,
|
||||||
|
reason=(
|
||||||
|
"Audit actions are immutable accountability evidence. Arbitrary "
|
||||||
|
"details and credentials are excluded from the access projection."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_record(row: AuditOutboxEvent) -> DsarRecordRef:
|
||||||
|
payload = row.payload if isinstance(row.payload, Mapping) else {}
|
||||||
|
actor = _object_projection(payload.get("actor"))
|
||||||
|
subject = _object_projection(payload.get("subject"))
|
||||||
|
resource = _object_projection(payload.get("resource"))
|
||||||
|
return _record(
|
||||||
|
"audit_event_actor_record",
|
||||||
|
row.id,
|
||||||
|
f"Platform event attribution: {row.event_type[:200]}",
|
||||||
|
{
|
||||||
|
"event_id": row.event_id,
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"module_id": row.module_id,
|
||||||
|
"correlation_id": row.correlation_id,
|
||||||
|
"causation_id": row.causation_id,
|
||||||
|
"classification": row.classification,
|
||||||
|
"actor": actor,
|
||||||
|
"subject": subject,
|
||||||
|
"resource": resource,
|
||||||
|
"occurred_at": _bounded_string(payload.get("occurred_at"), 100),
|
||||||
|
"status": row.status,
|
||||||
|
"attempts": row.attempts,
|
||||||
|
"next_attempt_at": _iso(row.next_attempt_at),
|
||||||
|
"dispatched_at": _iso(row.dispatched_at),
|
||||||
|
"recorded_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.created_at,
|
||||||
|
reason=(
|
||||||
|
"Platform event envelopes and actor attribution remain immutable. "
|
||||||
|
"The event payload, institutional context, and delivery errors are excluded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_record(
|
||||||
|
row: AuditOutboxDelivery,
|
||||||
|
event: AuditOutboxEvent,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"audit_replay_attribution",
|
||||||
|
row.id,
|
||||||
|
"Audit outbox replay attribution",
|
||||||
|
{
|
||||||
|
"activity": "replayed_platform_event_delivery",
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"module_id": event.module_id,
|
||||||
|
"consumer_id": row.consumer_id,
|
||||||
|
"status": row.status,
|
||||||
|
"policy_decision_ref": row.policy_decision_ref,
|
||||||
|
"replay_count": row.replay_count,
|
||||||
|
"last_replayed_at": _iso(row.last_replayed_at),
|
||||||
|
"recorded_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.last_replayed_at or row.updated_at,
|
||||||
|
reason=(
|
||||||
|
"Manual replay attribution is immutable operational evidence. Replay "
|
||||||
|
"reasons, delivery keys, and provider errors are excluded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_record(row: AuditEvidenceBundle) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"audit_evidence_bundle_attribution",
|
||||||
|
row.id,
|
||||||
|
"Audit evidence-bundle request attribution",
|
||||||
|
{
|
||||||
|
"activity": "requested_audit_evidence_bundle",
|
||||||
|
"scope": row.scope,
|
||||||
|
"status": row.status,
|
||||||
|
"record_count": row.record_count,
|
||||||
|
"reference_count": row.reference_count,
|
||||||
|
"bundle_sha256": row.bundle_sha256,
|
||||||
|
"generated_at": _iso(row.generated_at),
|
||||||
|
"downloaded_at": _iso(row.downloaded_at),
|
||||||
|
"error_code": row.error_code,
|
||||||
|
"requested_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.updated_at,
|
||||||
|
reason=(
|
||||||
|
"Evidence-bundle request attribution and verification hashes are "
|
||||||
|
"immutable. Request and bundle payloads are excluded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
title: str,
|
||||||
|
data: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None,
|
||||||
|
reason: str,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="audit",
|
||||||
|
module_id="audit",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category="immutable_accountability_evidence",
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=_aware(observed_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_context(
|
||||||
|
details: Mapping[object, object],
|
||||||
|
allowed_keys: Sequence[str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
key: _bounded_reference(details[key], depth=0)
|
||||||
|
for key in allowed_keys
|
||||||
|
if key in details
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_reference(value: object, *, depth: int) -> object:
|
||||||
|
if depth > 4:
|
||||||
|
return {"redacted": True, "reason": "depth_limit"}
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:2_048]
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {
|
||||||
|
str(key)[:200]: _bounded_reference(nested, depth=depth + 1)
|
||||||
|
for key, nested in list(value.items())[:50]
|
||||||
|
if not _sensitive_key(str(key))
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_bounded_reference(item, depth=depth + 1) for item in value[:50]]
|
||||||
|
return {"redacted": True, "type": type(value).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
def _object_projection(value: object) -> dict[str, str | None] | None:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"type": _bounded_string(value.get("type"), 100),
|
||||||
|
"id": _bounded_string(value.get("id"), 255),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_string(value: object, limit: int) -> str | None:
|
||||||
|
return str(value)[:limit] if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _sensitive_key(value: str) -> bool:
|
||||||
|
normalized = value.strip().casefold().replace("-", "_")
|
||||||
|
return any(
|
||||||
|
part in normalized
|
||||||
|
for part in (
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"credential",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Audit DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "audit" or record.module_id != "audit":
|
||||||
|
raise ValueError("Audit DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||||
|
raise ValueError("Audit DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "audit" or action.module_id != "audit":
|
||||||
|
raise ValueError("Audit DSAR cannot execute a foreign provider action.")
|
||||||
|
if not action.action_id.startswith("audit:"):
|
||||||
|
raise ValueError("Audit DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AUDIT_DSAR_CAPABILITY", "AuditDsarProvider"]
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
EVIDENCE_BUNDLE_SCHEMA = "govoplan.audit.evidence-bundle"
|
||||||
|
EVIDENCE_BUNDLE_VERSION = "1.0"
|
||||||
|
EVIDENCE_REDACTION_PROFILE = "bounded-v1"
|
||||||
|
MAX_EVIDENCE_RECORDS = 500
|
||||||
|
MAX_EVIDENCE_REFERENCES = 200
|
||||||
|
MAX_SANITIZED_DETAILS_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
_TRACE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"correlation_id",
|
||||||
|
"causation_id",
|
||||||
|
"request_id",
|
||||||
|
"run_id",
|
||||||
|
"trace_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_REFERENCE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"evidence_ref",
|
||||||
|
"evidence_refs",
|
||||||
|
"legal_basis_ref",
|
||||||
|
"policy_decision_ref",
|
||||||
|
"policy_ref",
|
||||||
|
"source_ref",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_PROHIBITED_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"authorization",
|
||||||
|
"body",
|
||||||
|
"content_bytes",
|
||||||
|
"credential",
|
||||||
|
"credentials",
|
||||||
|
"file_content",
|
||||||
|
"file_contents",
|
||||||
|
"message",
|
||||||
|
"message_body",
|
||||||
|
"password",
|
||||||
|
"payload",
|
||||||
|
"raw_message",
|
||||||
|
"recipient_list",
|
||||||
|
"recipients",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_SENSITIVE_KEY_PARTS = ("password", "secret", "credential", "authorization_token")
|
||||||
|
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
_SENSITIVE_LOCATOR_RE = re.compile(
|
||||||
|
r"(?i)(?:^|[?&;])(?:access_?token|token|secret|password|credential|authorization|signature)=[^&;]+|://[^/@\s]+:[^/@\s]+@"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceBundleError(ValueError):
|
||||||
|
"""Stable error for bounded Audit evidence-bundle generation."""
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_sha256(value: object) -> str:
|
||||||
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_audit_details(value: object) -> tuple[object, list[str]]:
|
||||||
|
redactions: list[str] = []
|
||||||
|
|
||||||
|
def sanitize(candidate: object, path: str) -> object:
|
||||||
|
if isinstance(candidate, Mapping):
|
||||||
|
result: dict[str, object] = {}
|
||||||
|
for raw_key, nested in sorted(candidate.items(), key=lambda item: str(item[0])):
|
||||||
|
key = str(raw_key)
|
||||||
|
normalized = key.strip().lower().replace("-", "_")
|
||||||
|
nested_path = f"{path}.{key}" if path else key
|
||||||
|
if _is_prohibited_key(normalized):
|
||||||
|
result[key] = _redacted_summary(nested)
|
||||||
|
redactions.append(nested_path)
|
||||||
|
else:
|
||||||
|
result[key] = sanitize(nested, nested_path)
|
||||||
|
return result
|
||||||
|
if isinstance(candidate, (list, tuple)):
|
||||||
|
return [sanitize(item, f"{path}[{index}]") for index, item in enumerate(candidate)]
|
||||||
|
if candidate is None or isinstance(candidate, (bool, int, float, str)):
|
||||||
|
return candidate
|
||||||
|
redactions.append(path or "details")
|
||||||
|
return {"redacted": True, "type": type(candidate).__name__}
|
||||||
|
|
||||||
|
sanitized = sanitize(value, "details")
|
||||||
|
if len(canonical_json_bytes(sanitized)) > MAX_SANITIZED_DETAILS_BYTES:
|
||||||
|
redactions.append("details")
|
||||||
|
sanitized = {
|
||||||
|
"redacted": True,
|
||||||
|
"reason": "sanitized_details_size_limit",
|
||||||
|
"original_type": type(value).__name__,
|
||||||
|
}
|
||||||
|
return sanitized, sorted(set(redactions))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_prohibited_key(normalized: str) -> bool:
|
||||||
|
return (
|
||||||
|
normalized in _PROHIBITED_KEYS
|
||||||
|
or normalized.endswith("_token")
|
||||||
|
or any(part in normalized for part in _SENSITIVE_KEY_PARTS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_summary(value: object) -> dict[str, object]:
|
||||||
|
summary: dict[str, object] = {"redacted": True}
|
||||||
|
if isinstance(value, (list, tuple, Mapping)):
|
||||||
|
summary["item_count"] = len(value)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _record_payload(record: AuditLog) -> dict[str, object]:
|
||||||
|
sanitized, redactions = sanitize_audit_details(record.details or {})
|
||||||
|
details = sanitized if isinstance(sanitized, dict) else {"value": sanitized}
|
||||||
|
return {
|
||||||
|
"record_id": record.id,
|
||||||
|
"scope": record.scope,
|
||||||
|
"tenant_id": record.tenant_id,
|
||||||
|
"actor": {
|
||||||
|
"user_id": record.user_id,
|
||||||
|
"api_key_id": record.api_key_id,
|
||||||
|
},
|
||||||
|
"action": record.action,
|
||||||
|
"object": {
|
||||||
|
"type": record.object_type,
|
||||||
|
"id": record.object_id,
|
||||||
|
},
|
||||||
|
"recorded_at": _datetime_text(record.created_at),
|
||||||
|
"trace_context": _bounded_context(details, _TRACE_KEYS),
|
||||||
|
"policy_source_provenance": _bounded_context(details, _REFERENCE_KEYS),
|
||||||
|
"details": details,
|
||||||
|
"redaction": {
|
||||||
|
"profile": EVIDENCE_REDACTION_PROFILE,
|
||||||
|
"redacted_paths": redactions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_context(details: Mapping[str, object], keys: frozenset[str]) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {}
|
||||||
|
for key in sorted(keys):
|
||||||
|
if key not in details:
|
||||||
|
continue
|
||||||
|
value = details[key]
|
||||||
|
if isinstance(value, str):
|
||||||
|
result[key] = value[:2048]
|
||||||
|
elif isinstance(value, list):
|
||||||
|
result[key] = [_bounded_reference_value(item) for item in value[:50]]
|
||||||
|
elif isinstance(value, Mapping):
|
||||||
|
result[key] = _bounded_reference_value(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_reference_value(value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:2048]
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {
|
||||||
|
str(key): _bounded_reference_value(nested)
|
||||||
|
for key, nested in sorted(value.items(), key=lambda item: str(item[0]))
|
||||||
|
if str(key).lower() not in _PROHIBITED_KEYS
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_bounded_reference_value(item) for item in value[:50]]
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
return str(value)[:2048]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_evidence_reference(value: Mapping[str, object]) -> dict[str, object]:
|
||||||
|
reference_id = _required_text(value, "reference_id", maximum=200)
|
||||||
|
kind = _required_text(value, "kind", maximum=80)
|
||||||
|
owner_module = _required_text(value, "owner_module", maximum=100)
|
||||||
|
locator = _required_text(value, "locator", maximum=2048)
|
||||||
|
if _SENSITIVE_LOCATOR_RE.search(locator):
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
f"Evidence reference {reference_id!r} locator appears to contain a secret."
|
||||||
|
)
|
||||||
|
content_sha256 = _optional_text(value.get("content_sha256"), maximum=64)
|
||||||
|
if content_sha256 is not None:
|
||||||
|
content_sha256 = content_sha256.lower()
|
||||||
|
if not _SHA256_RE.fullmatch(content_sha256):
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
f"Evidence reference {reference_id!r} has an invalid SHA-256 checksum."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reference_id": reference_id,
|
||||||
|
"kind": kind,
|
||||||
|
"owner_module": owner_module,
|
||||||
|
"locator": locator,
|
||||||
|
"content_sha256": content_sha256,
|
||||||
|
"required": bool(value.get("required", True)),
|
||||||
|
"availability": "external",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_evidence_bundle(
|
||||||
|
records: Iterable[AuditLog],
|
||||||
|
*,
|
||||||
|
bundle_id: str,
|
||||||
|
generated_at: datetime,
|
||||||
|
scope: Mapping[str, object],
|
||||||
|
request: Mapping[str, object],
|
||||||
|
references: Iterable[Mapping[str, object]] = (),
|
||||||
|
signing_key_id: str | None = None,
|
||||||
|
signing_private_key_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
record_payloads = [_record_payload(record) for record in records]
|
||||||
|
if len(record_payloads) > MAX_EVIDENCE_RECORDS:
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
f"Evidence bundles support at most {MAX_EVIDENCE_RECORDS} audit records."
|
||||||
|
)
|
||||||
|
reference_payloads = [normalize_evidence_reference(item) for item in references]
|
||||||
|
if len(reference_payloads) > MAX_EVIDENCE_REFERENCES:
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
f"Evidence bundles support at most {MAX_EVIDENCE_REFERENCES} external references."
|
||||||
|
)
|
||||||
|
reference_ids = [str(item["reference_id"]) for item in reference_payloads]
|
||||||
|
if len(reference_ids) != len(set(reference_ids)):
|
||||||
|
raise EvidenceBundleError("Evidence reference ids must be unique within a bundle.")
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"path": f"records/{index}",
|
||||||
|
"kind": "audit_record",
|
||||||
|
"sha256": canonical_sha256(payload),
|
||||||
|
}
|
||||||
|
for index, payload in enumerate(record_payloads)
|
||||||
|
]
|
||||||
|
entries.extend(
|
||||||
|
{
|
||||||
|
"path": f"references/{index}",
|
||||||
|
"kind": "external_reference",
|
||||||
|
"sha256": canonical_sha256(payload),
|
||||||
|
}
|
||||||
|
for index, payload in enumerate(reference_payloads)
|
||||||
|
)
|
||||||
|
manifest_core: dict[str, object] = {
|
||||||
|
"schema": EVIDENCE_BUNDLE_SCHEMA,
|
||||||
|
"version": EVIDENCE_BUNDLE_VERSION,
|
||||||
|
"bundle_id": _required_scalar_text(bundle_id, "Bundle id", maximum=100),
|
||||||
|
"generated_at": _datetime_text(generated_at),
|
||||||
|
"scope": dict(scope),
|
||||||
|
"request": dict(request),
|
||||||
|
"record_count": len(record_payloads),
|
||||||
|
"reference_count": len(reference_payloads),
|
||||||
|
"entries": entries,
|
||||||
|
"redaction": {
|
||||||
|
"profile": EVIDENCE_REDACTION_PROFILE,
|
||||||
|
"prohibited_fields": sorted(_PROHIBITED_KEYS),
|
||||||
|
"raw_evidence_embedded": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
manifest_sha256 = canonical_sha256(manifest_core)
|
||||||
|
signed_manifest = {**manifest_core, "manifest_sha256": manifest_sha256}
|
||||||
|
signatures: list[dict[str, str]] = []
|
||||||
|
if signing_key_id is not None or signing_private_key_path is not None:
|
||||||
|
if not signing_key_id or signing_private_key_path is None:
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
"Evidence signing requires both a key id and an Ed25519 private-key path."
|
||||||
|
)
|
||||||
|
signatures.append(
|
||||||
|
_sign_manifest(
|
||||||
|
signed_manifest,
|
||||||
|
key_id=signing_key_id,
|
||||||
|
private_key_path=signing_private_key_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manifest = {**signed_manifest, "signatures": signatures}
|
||||||
|
return {
|
||||||
|
"schema": EVIDENCE_BUNDLE_SCHEMA,
|
||||||
|
"version": EVIDENCE_BUNDLE_VERSION,
|
||||||
|
"manifest": manifest,
|
||||||
|
"records": record_payloads,
|
||||||
|
"references": reference_payloads,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configured_signing_key(*, required: bool) -> tuple[str | None, Path | None]:
|
||||||
|
key_id = os.getenv("GOVOPLAN_AUDIT_EVIDENCE_SIGNING_KEY_ID", "").strip()
|
||||||
|
raw_path = os.getenv("GOVOPLAN_AUDIT_EVIDENCE_SIGNING_PRIVATE_KEY", "").strip()
|
||||||
|
if not key_id and not raw_path and not required:
|
||||||
|
return None, None
|
||||||
|
if not key_id or not raw_path:
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
"Evidence signing is not fully configured; set both signing key environment variables."
|
||||||
|
)
|
||||||
|
path = Path(raw_path)
|
||||||
|
if not path.is_file():
|
||||||
|
raise EvidenceBundleError("The configured evidence signing private key is unavailable.")
|
||||||
|
return key_id, path
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_manifest(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
key_id: str,
|
||||||
|
private_key_path: Path,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
private_key = serialization.load_pem_private_key(
|
||||||
|
private_key_path.read_bytes(),
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
except (OSError, TypeError, ValueError) as exc:
|
||||||
|
raise EvidenceBundleError(
|
||||||
|
"The configured evidence signing private key could not be loaded."
|
||||||
|
) from exc
|
||||||
|
if not isinstance(private_key, Ed25519PrivateKey):
|
||||||
|
raise EvidenceBundleError("Evidence signing requires an Ed25519 private key.")
|
||||||
|
signature = private_key.sign(canonical_json_bytes(manifest))
|
||||||
|
return {
|
||||||
|
"algorithm": "ed25519",
|
||||||
|
"key_id": _required_scalar_text(key_id, "Signing key id", maximum=200),
|
||||||
|
"value": base64.b64encode(signature).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(
|
||||||
|
value: Mapping[str, object],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
) -> str:
|
||||||
|
return _required_scalar_text(value.get(key), key.replace("_", " ").title(), maximum=maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_scalar_text(value: object, label: str, *, maximum: int) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise EvidenceBundleError(f"{label} is required.")
|
||||||
|
clean = value.strip()
|
||||||
|
if len(clean) > maximum:
|
||||||
|
raise EvidenceBundleError(f"{label} is too long.")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object, *, maximum: int) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
|
||||||
|
raise EvidenceBundleError("Evidence reference text is invalid.")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_text(value: datetime) -> str:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EVIDENCE_BUNDLE_SCHEMA",
|
||||||
|
"EVIDENCE_BUNDLE_VERSION",
|
||||||
|
"EVIDENCE_REDACTION_PROFILE",
|
||||||
|
"EvidenceBundleError",
|
||||||
|
"MAX_EVIDENCE_RECORDS",
|
||||||
|
"MAX_EVIDENCE_REFERENCES",
|
||||||
|
"build_evidence_bundle",
|
||||||
|
"canonical_json_bytes",
|
||||||
|
"canonical_sha256",
|
||||||
|
"configured_signing_key",
|
||||||
|
"normalize_evidence_reference",
|
||||||
|
"sanitize_audit_details",
|
||||||
|
]
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
|
from govoplan_audit.backend.evidence_bundles import (
|
||||||
|
EVIDENCE_BUNDLE_SCHEMA,
|
||||||
|
EVIDENCE_BUNDLE_VERSION,
|
||||||
|
canonical_json_bytes,
|
||||||
|
canonical_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, order=True)
|
||||||
|
class VerificationFinding:
|
||||||
|
code: str
|
||||||
|
path: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {"code": self.code, "path": self.path, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_evidence_bundle(
|
||||||
|
payload: object,
|
||||||
|
*,
|
||||||
|
trusted_keys: Mapping[str, str] | None = None,
|
||||||
|
external_evidence: Mapping[str, bytes] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
errors: list[VerificationFinding] = []
|
||||||
|
warnings: list[VerificationFinding] = []
|
||||||
|
trusted_keys = trusted_keys or {}
|
||||||
|
external_evidence = external_evidence or {}
|
||||||
|
if isinstance(payload, Mapping) and isinstance(payload.get("bundle"), Mapping):
|
||||||
|
payload = payload["bundle"]
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return _result(
|
||||||
|
status="invalid",
|
||||||
|
errors=[_finding("invalid_schema", "$", "Bundle must be a JSON object.")],
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
if payload.get("schema") != EVIDENCE_BUNDLE_SCHEMA:
|
||||||
|
return _result(
|
||||||
|
status="unsupported",
|
||||||
|
errors=[
|
||||||
|
_finding(
|
||||||
|
"unsupported_schema",
|
||||||
|
"schema",
|
||||||
|
f"Unsupported evidence-bundle schema: {payload.get('schema')!r}.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
if payload.get("version") != EVIDENCE_BUNDLE_VERSION:
|
||||||
|
return _result(
|
||||||
|
status="unsupported",
|
||||||
|
errors=[
|
||||||
|
_finding(
|
||||||
|
"unsupported_version",
|
||||||
|
"version",
|
||||||
|
f"Unsupported evidence-bundle version: {payload.get('version')!r}.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = payload.get("manifest")
|
||||||
|
records = payload.get("records")
|
||||||
|
references = payload.get("references")
|
||||||
|
if not isinstance(manifest, Mapping):
|
||||||
|
errors.append(_finding("invalid_manifest", "manifest", "Manifest must be an object."))
|
||||||
|
manifest = {}
|
||||||
|
if not isinstance(records, list):
|
||||||
|
errors.append(_finding("invalid_records", "records", "Records must be an array."))
|
||||||
|
records = []
|
||||||
|
if not isinstance(references, list):
|
||||||
|
errors.append(_finding("invalid_references", "references", "References must be an array."))
|
||||||
|
references = []
|
||||||
|
|
||||||
|
_verify_manifest_contract(manifest, errors)
|
||||||
|
_verify_manifest_hash(manifest, errors)
|
||||||
|
_verify_entries(manifest, records, references, errors)
|
||||||
|
_verify_counts(manifest, records, references, errors)
|
||||||
|
_verify_selection(manifest, records, references, errors)
|
||||||
|
signature_state = _verify_signatures(manifest, trusted_keys, errors, warnings)
|
||||||
|
_verify_redaction(manifest, records, errors)
|
||||||
|
_verify_external_references(
|
||||||
|
references,
|
||||||
|
external_evidence=external_evidence,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = _status(errors, warnings)
|
||||||
|
return _result(
|
||||||
|
status=status,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
signature_state=signature_state,
|
||||||
|
record_count=len(records),
|
||||||
|
reference_count=len(references),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_manifest_hash(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
expected = manifest.get("manifest_sha256")
|
||||||
|
if not isinstance(expected, str):
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"manifest_hash_missing",
|
||||||
|
"manifest.manifest_sha256",
|
||||||
|
"Manifest SHA-256 is missing.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
core = dict(manifest)
|
||||||
|
core.pop("manifest_sha256", None)
|
||||||
|
core.pop("signatures", None)
|
||||||
|
if canonical_sha256(core) != expected:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"manifest_hash_mismatch",
|
||||||
|
"manifest.manifest_sha256",
|
||||||
|
"Manifest canonical hash does not match its contents.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_manifest_contract(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
if manifest.get("schema") != EVIDENCE_BUNDLE_SCHEMA:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"invalid_manifest_schema",
|
||||||
|
"manifest.schema",
|
||||||
|
"Manifest schema does not match the supported bundle schema.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if manifest.get("version") != EVIDENCE_BUNDLE_VERSION:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"invalid_manifest_version",
|
||||||
|
"manifest.version",
|
||||||
|
"Manifest version does not match the supported bundle version.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in ("bundle_id", "generated_at"):
|
||||||
|
if not isinstance(manifest.get(key), str) or not manifest.get(key):
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"invalid_manifest_field",
|
||||||
|
f"manifest.{key}",
|
||||||
|
f"Manifest {key} is required.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_entries(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
records: list[object],
|
||||||
|
references: list[object],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
raw_entries = manifest.get("entries")
|
||||||
|
if not isinstance(raw_entries, list):
|
||||||
|
errors.append(
|
||||||
|
_finding("manifest_entries_missing", "manifest.entries", "Manifest entries are missing.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
entries: dict[str, Mapping[str, object]] = {}
|
||||||
|
for index, item in enumerate(raw_entries):
|
||||||
|
path = f"manifest.entries[{index}]"
|
||||||
|
if not isinstance(item, Mapping) or not isinstance(item.get("path"), str):
|
||||||
|
errors.append(_finding("invalid_manifest_entry", path, "Manifest entry is invalid."))
|
||||||
|
continue
|
||||||
|
entry_path = str(item["path"])
|
||||||
|
if entry_path in entries:
|
||||||
|
errors.append(
|
||||||
|
_finding("duplicate_manifest_entry", path, f"Duplicate manifest path: {entry_path}.")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
entries[entry_path] = item
|
||||||
|
|
||||||
|
expected_items = {
|
||||||
|
**{f"records/{index}": item for index, item in enumerate(records)},
|
||||||
|
**{f"references/{index}": item for index, item in enumerate(references)},
|
||||||
|
}
|
||||||
|
for path, item in sorted(expected_items.items()):
|
||||||
|
entry = entries.get(path)
|
||||||
|
if entry is None:
|
||||||
|
errors.append(
|
||||||
|
_finding("manifest_entry_missing", path, "Included item has no manifest entry.")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
digest = entry.get("sha256")
|
||||||
|
if not isinstance(digest, str) or canonical_sha256(item) != digest:
|
||||||
|
errors.append(
|
||||||
|
_finding("item_hash_mismatch", path, "Included item hash does not match the manifest.")
|
||||||
|
)
|
||||||
|
for path in sorted(set(entries) - set(expected_items)):
|
||||||
|
errors.append(
|
||||||
|
_finding("included_item_missing", path, "Manifest entry has no included item.")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_counts(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
records: list[object],
|
||||||
|
references: list[object],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
if manifest.get("record_count") != len(records):
|
||||||
|
errors.append(
|
||||||
|
_finding("record_count_mismatch", "manifest.record_count", "Record count is incomplete.")
|
||||||
|
)
|
||||||
|
if manifest.get("reference_count") != len(references):
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"reference_count_mismatch",
|
||||||
|
"manifest.reference_count",
|
||||||
|
"Reference count is incomplete.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_selection(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
records: list[object],
|
||||||
|
references: list[object],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
request = manifest.get("request")
|
||||||
|
if not isinstance(request, Mapping):
|
||||||
|
errors.append(
|
||||||
|
_finding("selection_incomplete", "manifest.request", "Selection declaration is missing.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if request.get("selection_complete") is not True:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"selection_incomplete",
|
||||||
|
"manifest.request.selection_complete",
|
||||||
|
"Exporter did not declare the selected audit-record set complete.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
requested_ids = request.get("record_ids")
|
||||||
|
if isinstance(requested_ids, list) and requested_ids:
|
||||||
|
included_ids = {
|
||||||
|
item.get("record_id")
|
||||||
|
for item in records
|
||||||
|
if isinstance(item, Mapping) and isinstance(item.get("record_id"), str)
|
||||||
|
}
|
||||||
|
if set(requested_ids) != included_ids:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"selection_incomplete",
|
||||||
|
"manifest.request.record_ids",
|
||||||
|
"Requested audit record ids do not match the included records.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
requested_references = request.get("reference_ids")
|
||||||
|
if isinstance(requested_references, list):
|
||||||
|
included_references = {
|
||||||
|
item.get("reference_id")
|
||||||
|
for item in references
|
||||||
|
if isinstance(item, Mapping) and isinstance(item.get("reference_id"), str)
|
||||||
|
}
|
||||||
|
if set(requested_references) != included_references:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"selection_incomplete",
|
||||||
|
"manifest.request.reference_ids",
|
||||||
|
"Requested evidence references do not match the included references.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_signatures(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
trusted_keys: Mapping[str, str],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
warnings: list[VerificationFinding],
|
||||||
|
) -> str:
|
||||||
|
signatures = manifest.get("signatures")
|
||||||
|
if not isinstance(signatures, list) or not signatures:
|
||||||
|
warnings.append(
|
||||||
|
_finding(
|
||||||
|
"manifest_unsigned",
|
||||||
|
"manifest.signatures",
|
||||||
|
"Manifest is unsigned; canonical hashes were still verified.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "unsigned"
|
||||||
|
signed_manifest = dict(manifest)
|
||||||
|
signed_manifest.pop("signatures", None)
|
||||||
|
trusted = False
|
||||||
|
for index, signature in enumerate(signatures):
|
||||||
|
path = f"manifest.signatures[{index}]"
|
||||||
|
if not isinstance(signature, Mapping):
|
||||||
|
errors.append(_finding("invalid_signature", path, "Signature must be an object."))
|
||||||
|
continue
|
||||||
|
algorithm = signature.get("algorithm")
|
||||||
|
key_id = signature.get("key_id")
|
||||||
|
value = signature.get("value")
|
||||||
|
if algorithm != "ed25519":
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"unsupported_signature_algorithm",
|
||||||
|
path,
|
||||||
|
f"Unsupported signature algorithm: {algorithm!r}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not isinstance(key_id, str) or not isinstance(value, str):
|
||||||
|
errors.append(_finding("invalid_signature", path, "Signature key and value are required."))
|
||||||
|
continue
|
||||||
|
trusted_value = trusted_keys.get(key_id)
|
||||||
|
if trusted_value is None:
|
||||||
|
warnings.append(
|
||||||
|
_finding(
|
||||||
|
"signature_key_untrusted",
|
||||||
|
path,
|
||||||
|
f"No trusted public key was supplied for {key_id!r}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
public_key = Ed25519PublicKey.from_public_bytes(
|
||||||
|
base64.b64decode(trusted_value, validate=True)
|
||||||
|
)
|
||||||
|
public_key.verify(
|
||||||
|
base64.b64decode(value, validate=True),
|
||||||
|
canonical_json_bytes(signed_manifest),
|
||||||
|
)
|
||||||
|
trusted = True
|
||||||
|
except (ValueError, InvalidSignature) as exc:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"signature_verification_failed",
|
||||||
|
path,
|
||||||
|
f"Trusted signature verification failed: {type(exc).__name__}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if trusted:
|
||||||
|
return "trusted"
|
||||||
|
return "unverifiable"
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_redaction(
|
||||||
|
manifest: Mapping[str, object],
|
||||||
|
records: list[object],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
redaction = manifest.get("redaction")
|
||||||
|
if not isinstance(redaction, Mapping) or redaction.get("raw_evidence_embedded") is not False:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"redaction_declaration_missing",
|
||||||
|
"manifest.redaction",
|
||||||
|
"Manifest must declare that raw evidence is not embedded.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index, record in enumerate(records):
|
||||||
|
if not isinstance(record, Mapping):
|
||||||
|
errors.append(
|
||||||
|
_finding("invalid_record", f"records/{index}", "Audit record must be an object.")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
declaration = record.get("redaction")
|
||||||
|
if not isinstance(declaration, Mapping) or not isinstance(
|
||||||
|
declaration.get("redacted_paths"), list
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"record_redaction_missing",
|
||||||
|
f"records/{index}.redaction",
|
||||||
|
"Record redaction declaration is missing.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_find_unredacted_prohibited_fields(record.get("details"), f"records/{index}.details", errors)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_unredacted_prohibited_fields(
|
||||||
|
value: object,
|
||||||
|
path: str,
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
prohibited = {
|
||||||
|
"authorization",
|
||||||
|
"body",
|
||||||
|
"content_bytes",
|
||||||
|
"credential",
|
||||||
|
"credentials",
|
||||||
|
"file_content",
|
||||||
|
"file_contents",
|
||||||
|
"message",
|
||||||
|
"message_body",
|
||||||
|
"password",
|
||||||
|
"payload",
|
||||||
|
"raw_message",
|
||||||
|
"recipient_list",
|
||||||
|
"recipients",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for raw_key, nested in value.items():
|
||||||
|
key = str(raw_key)
|
||||||
|
nested_path = f"{path}.{key}"
|
||||||
|
normalized = key.lower().replace("-", "_")
|
||||||
|
if (
|
||||||
|
normalized in prohibited
|
||||||
|
or normalized.endswith("_token")
|
||||||
|
or any(
|
||||||
|
part in normalized
|
||||||
|
for part in ("password", "secret", "credential", "authorization_token")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
if not (
|
||||||
|
isinstance(nested, Mapping)
|
||||||
|
and nested.get("redacted") is True
|
||||||
|
and set(nested).issubset({"redacted", "item_count"})
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"redaction_violation",
|
||||||
|
nested_path,
|
||||||
|
"Prohibited evidence content is not redacted.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_find_unredacted_prohibited_fields(nested, nested_path, errors)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, nested in enumerate(value):
|
||||||
|
_find_unredacted_prohibited_fields(nested, f"{path}[{index}]", errors)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_external_references(
|
||||||
|
references: list[object],
|
||||||
|
*,
|
||||||
|
external_evidence: Mapping[str, bytes],
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
warnings: list[VerificationFinding],
|
||||||
|
) -> None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, reference in enumerate(references):
|
||||||
|
path = f"references/{index}"
|
||||||
|
if not isinstance(reference, Mapping):
|
||||||
|
errors.append(_finding("invalid_reference", path, "Reference must be an object."))
|
||||||
|
continue
|
||||||
|
reference_id = reference.get("reference_id")
|
||||||
|
if not isinstance(reference_id, str) or not reference_id:
|
||||||
|
errors.append(_finding("invalid_reference", path, "Reference id is required."))
|
||||||
|
continue
|
||||||
|
if reference_id in seen:
|
||||||
|
errors.append(
|
||||||
|
_finding("duplicate_reference", path, f"Reference id {reference_id!r} is duplicated.")
|
||||||
|
)
|
||||||
|
seen.add(reference_id)
|
||||||
|
expected = reference.get("content_sha256")
|
||||||
|
evidence = external_evidence.get(reference_id)
|
||||||
|
if not isinstance(expected, str) or len(expected) != 64:
|
||||||
|
warnings.append(
|
||||||
|
_finding(
|
||||||
|
"reference_unverifiable",
|
||||||
|
path,
|
||||||
|
f"External evidence {reference_id!r} has no canonical content hash.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if evidence is None:
|
||||||
|
finding = _finding(
|
||||||
|
"external_evidence_missing",
|
||||||
|
path,
|
||||||
|
f"External evidence {reference_id!r} was not supplied to the verifier.",
|
||||||
|
)
|
||||||
|
if reference.get("required") is False:
|
||||||
|
warnings.append(finding)
|
||||||
|
else:
|
||||||
|
errors.append(finding)
|
||||||
|
continue
|
||||||
|
if hashlib.sha256(evidence).hexdigest() != expected:
|
||||||
|
errors.append(
|
||||||
|
_finding(
|
||||||
|
"external_evidence_hash_mismatch",
|
||||||
|
path,
|
||||||
|
f"External evidence {reference_id!r} does not match its declared hash.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _status(
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
warnings: list[VerificationFinding],
|
||||||
|
) -> str:
|
||||||
|
codes = {item.code for item in errors}
|
||||||
|
if any("hash_mismatch" in code or "signature_verification_failed" == code for code in codes):
|
||||||
|
return "tampered"
|
||||||
|
if any(code in {"manifest_entry_missing", "included_item_missing", "record_count_mismatch", "reference_count_mismatch", "selection_incomplete", "external_evidence_missing"} for code in codes):
|
||||||
|
return "incomplete"
|
||||||
|
if errors:
|
||||||
|
return "invalid"
|
||||||
|
if any(
|
||||||
|
item.code in {"reference_unverifiable", "signature_key_untrusted"}
|
||||||
|
for item in warnings
|
||||||
|
):
|
||||||
|
return "unverifiable"
|
||||||
|
return "valid"
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
errors: list[VerificationFinding],
|
||||||
|
warnings: list[VerificationFinding],
|
||||||
|
signature_state: str = "not_checked",
|
||||||
|
record_count: int = 0,
|
||||||
|
reference_count: int = 0,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"schema_supported": status != "unsupported",
|
||||||
|
"integrity_valid": status not in {"invalid", "tampered", "unsupported"},
|
||||||
|
"complete": status in {"valid", "unverifiable"},
|
||||||
|
"signature_state": signature_state,
|
||||||
|
"record_count": record_count,
|
||||||
|
"reference_count": reference_count,
|
||||||
|
"errors": [item.as_dict() for item in sorted(set(errors))],
|
||||||
|
"warnings": [item.as_dict() for item in sorted(set(warnings))],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(code: str, path: str, message: str) -> VerificationFinding:
|
||||||
|
return VerificationFinding(code=code, path=path, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["VerificationFinding", "verify_evidence_bundle"]
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_audit.backend.db import models as audit_models # noqa: F401 - populate Audit ORM metadata
|
from govoplan_audit.backend.db import models as audit_models # noqa: F401 - populate Audit ORM metadata
|
||||||
|
from govoplan_audit.backend.dsar_provider import (
|
||||||
|
AUDIT_DSAR_CAPABILITY,
|
||||||
|
AuditDsarProvider,
|
||||||
|
)
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
CAPABILITY_AUDIT_RETENTION,
|
CAPABILITY_AUDIT_RETENTION,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import (
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_audit.backend.permissions import AUDIT_PERMISSIONS, AUDIT_ROLE_TEMPLATES
|
||||||
|
|
||||||
|
|
||||||
def _route_factory(context: ModuleContext):
|
def _route_factory(context: ModuleContext):
|
||||||
@@ -33,30 +55,272 @@ def _audit_retention(context: ModuleContext):
|
|||||||
return SqlAuditRetentionProvider()
|
return SqlAuditRetentionProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _event_outbox(context: ModuleContext):
|
||||||
|
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
||||||
|
|
||||||
|
return SqlAuditOutbox(
|
||||||
|
max_attempts=getattr(
|
||||||
|
context.settings,
|
||||||
|
"platform_event_outbox_max_attempts",
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> AuditDsarProvider:
|
||||||
|
return AuditDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="audit",
|
id="audit",
|
||||||
name="Audit",
|
name="Audit",
|
||||||
version="0.1.8",
|
version="0.1.19",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
permissions=AUDIT_PERMISSIONS,
|
||||||
|
role_templates=AUDIT_ROLE_TEMPLATES,
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name=AUDIT_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="audit.data-subject-requests",
|
||||||
|
title="Audit data-subject requests",
|
||||||
|
summary=(
|
||||||
|
"Include minimized subject-linked accountability evidence in "
|
||||||
|
"access packages while preserving immutable retention."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Audit correlates exact account, membership, identity, or user "
|
||||||
|
"identifiers only within the active tenant. It contributes actor "
|
||||||
|
"audit records, structured platform-event actor envelopes, manual "
|
||||||
|
"replay attribution, and evidence-bundle request attribution. The "
|
||||||
|
"projection preserves action, object, event, trace, source, policy, "
|
||||||
|
"status, timing, and verification references needed to explain the "
|
||||||
|
"subject's involvement. Arbitrary audit details, event payloads, "
|
||||||
|
"institutional context, delivery keys and errors, replay reasons, "
|
||||||
|
"request payloads, generated bundle payloads, and credentials are "
|
||||||
|
"excluded. Optional exact record references narrow results but never "
|
||||||
|
"replace actor corroboration. System and cross-tenant evidence is not "
|
||||||
|
"included in tenant requests. All Audit erasure actions are retain-only "
|
||||||
|
"and non-executable because the records are immutable accountability "
|
||||||
|
"evidence governed by retention and legal hold."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("data_subject", "auditor", "security_officer", "operator"),
|
||||||
|
related_modules=("core", "access", "policy", "ops"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen für Audit",
|
||||||
|
"summary": "Minimierte, betroffenenbezogene Rechenschaftsnachweise in Auskunftspakete aufnehmen und dabei die unveränderliche Aufbewahrung wahren.",
|
||||||
|
"body": (
|
||||||
|
"Audit korreliert genaue Konto-, Mitgliedschafts-, Identitäts- oder Benutzerkennungen ausschließlich im aktiven Mandanten. "
|
||||||
|
"Beigetragen werden Akteursdaten aus Audit-Einträgen, strukturierte Akteursumschläge von Plattformereignissen, Zuordnungen manueller Wiederholungen und Anforderungszuordnungen von Nachweispaketen. "
|
||||||
|
"Die Projektion erhält Handlung, Objekt, Ereignis, Trace, Quelle, Richtlinie, Status, Zeitangaben und Prüfverweise, die zur Erklärung der Beteiligung der betroffenen Person erforderlich sind. "
|
||||||
|
"Beliebige Audit-Details, Ereignisnutzdaten, institutioneller Kontext, Zustellschlüssel und -fehler, Wiederholungsgründe, Anfragenutzdaten, erzeugte Paketnutzdaten und Zugangsdaten bleiben ausgeschlossen. "
|
||||||
|
"Optionale genaue Datensatzverweise grenzen Ergebnisse ein, ersetzen aber niemals die Bestätigung der Akteurszuordnung. Systemweite und mandantenübergreifende Nachweise werden nicht in Mandantenanfragen aufgenommen. "
|
||||||
|
"Alle Löschaktionen von Audit sind reine Aufbewahrungsentscheidungen und nicht ausführbar, weil die Datensätze unveränderliche Rechenschaftsnachweise unter Aufbewahrung und rechtlicher Sperre sind."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"help_contexts": [
|
||||||
|
"audit.admin.tenant",
|
||||||
|
"audit.event-details",
|
||||||
|
"audit.evidence.export",
|
||||||
|
"privacy.data-subject-requests",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_actor_evidence": (
|
||||||
|
"Returns minimized actor attribution and stable references, "
|
||||||
|
"never arbitrary evidence payloads."
|
||||||
|
),
|
||||||
|
"retain_audit_evidence": (
|
||||||
|
"Keeps immutable evidence under configured retention and "
|
||||||
|
"legal-hold policy."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="audit.read-authorized-evidence",
|
||||||
|
title="Read authorized audit evidence",
|
||||||
|
summary="Audit history explains who performed a governed action, when it happened, and which resource and trace context were involved.",
|
||||||
|
body="Audit views are permission- and tenant-scoped. Entries are evidence, not editable business records. Sensitive payloads may be redacted while stable resource, actor, outcome, request, run, and trace references remain available for investigation.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("auditor", "tenant_admin", "operator"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Berechtigte Audit-Nachweise lesen",
|
||||||
|
"summary": "Der Audit-Verlauf erklärt, wer eine gesteuerte Handlung wann ausgeführt hat und welche Ressourcen- und Trace-Zusammenhänge beteiligt waren.",
|
||||||
|
"body": (
|
||||||
|
"Audit-Ansichten sind nach Berechtigung und Mandant begrenzt. Einträge sind Nachweise und keine bearbeitbaren Fachdaten. "
|
||||||
|
"Sensible Nutzdaten können geschwärzt sein, während stabile Verweise auf Ressource, Akteur, Ergebnis, Anfrage, Lauf und Trace für Untersuchungen verfügbar bleiben."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"audit.admin.system",
|
||||||
|
"audit.admin.tenant",
|
||||||
|
"audit.event-details",
|
||||||
|
],
|
||||||
|
"surfaces": ["audit.admin.system", "audit.admin.tenant"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="audit.recording-retention-and-outbox",
|
||||||
|
title="Operate audit recording and event delivery",
|
||||||
|
summary="Audit owns durable audit records, retention operations, and the transactional platform-event outbox.",
|
||||||
|
body="Modules record bounded audit facts through the Audit capability. Governed platform events are committed to the outbox with retry and delivery metadata so a failed consumer does not erase the originating transaction. Worker dispatch is partitioned by tenant entitlement; an unavailable consumer retains its durable delivery and records an operator-required outcome instead of acknowledging the event. Retention and destructive retirement must preserve the configured evidence and recovery guarantees.",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("auditor", "security_officer", "operator"),
|
||||||
|
related_modules=("policy", "ops"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Audit-Aufzeichnung, Aufbewahrung und Ereigniszustellung betreiben",
|
||||||
|
"summary": "Audit verwaltet dauerhafte Audit-Datensätze, Aufbewahrungsvorgänge und die transaktionale Plattformereignis-Outbox.",
|
||||||
|
"body": (
|
||||||
|
"Module zeichnen begrenzte Audit-Fakten über die Audit-Fähigkeit auf. Gesteuerte Plattformereignisse werden zusammen mit Wiederholungs- und Zustellmetadaten in die Outbox übernommen, sodass ein ausgefallener Verbraucher die ursprüngliche Transaktion nicht auslöscht. "
|
||||||
|
"Die Worker-Zustellung wird nach Mandantenberechtigung getrennt; ein nicht verfügbarer Verbraucher behält seine dauerhafte Zustellung und erfasst ein Ergebnis, das einen betrieblichen Eingriff verlangt, statt das Ereignis zu bestätigen. "
|
||||||
|
"Aufbewahrung und destruktive Ausmusterung müssen die konfigurierten Nachweis- und Wiederherstellungsgarantien erhalten."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"audit.recording",
|
||||||
|
"audit.retention",
|
||||||
|
"audit.event-outbox",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="audit.evidence-bundles",
|
||||||
|
title="Export and independently verify audit evidence",
|
||||||
|
summary="Authorized auditors can export bounded, redacted evidence bundles with canonical hashes and optional trusted signatures.",
|
||||||
|
body="Tenant exports require audit:evidence:export and remain tenant-scoped; system or all-scope exports require audit:system_evidence:export. Each bundle contains versioned audit-record DTOs, trace and policy/source provenance, external module evidence references, redaction declarations, and canonical hashes, but never raw messages, recipient lists, secrets, credentials, or file contents. Generation and download are audited. Use govoplan-audit-verify with optional trusted Ed25519 public keys and external evidence files to distinguish valid, incomplete, unverifiable, unsupported, and tampered evidence without database access. Modules contribute external references through serialized Core EvidenceReference-compatible facts or the export request; they do not import Audit internals.",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("auditor", "security_officer", "operator"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(required_scopes=("audit:evidence:export",)),
|
||||||
|
DocumentationCondition(
|
||||||
|
required_scopes=("audit:system_evidence:export",)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("policy", "files"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Audit-Nachweise exportieren und unabhängig prüfen",
|
||||||
|
"summary": "Berechtigte Prüfer können begrenzte, geschwärzte Nachweispakete mit kanonischen Hashes und optionalen vertrauenswürdigen Signaturen exportieren.",
|
||||||
|
"body": (
|
||||||
|
"Mandantenexporte erfordern audit:evidence:export und bleiben mandantenbezogen; systemweite oder unbeschränkte Exporte erfordern audit:system_evidence:export. "
|
||||||
|
"Jedes Paket enthält versionierte Audit-Datensatz-DTOs, Trace- sowie Richtlinien- und Quellenherkunft, externe Nachweisverweise anderer Module, Schwärzungserklärungen und kanonische Hashes, jedoch niemals vollständige Nachrichten, Empfängerlisten, Geheimnisse, Zugangsdaten oder Dateiinhalte. "
|
||||||
|
"Erzeugung und Download werden auditiert. Verwenden Sie govoplan-audit-verify mit optionalen vertrauenswürdigen öffentlichen Ed25519-Schlüsseln und externen Nachweisdateien, um gültige, unvollständige, nicht prüfbare, nicht unterstützte und manipulierte Nachweise ohne Datenbankzugriff zu unterscheiden. "
|
||||||
|
"Module liefern externe Verweise als serialisierte, mit Core EvidenceReference kompatible Fakten oder über die Exportanfrage; sie importieren keine Audit-Interna."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"audit.evidence.export",
|
||||||
|
"audit.evidence.verify",
|
||||||
|
"audit.evidence.signing",
|
||||||
|
],
|
||||||
|
"verification": [
|
||||||
|
"Confirm the requested record scope is complete and bounded before export.",
|
||||||
|
"Verify canonical hashes offline and supply trusted keys or referenced evidence when required.",
|
||||||
|
"Treat missing external evidence, unverifiable references, and hash mismatches as distinct outcomes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="audit",
|
module_id="audit",
|
||||||
package_name="@govoplan/audit-webui",
|
package_name="@govoplan/audit-webui",
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="audit.admin.system",
|
||||||
|
module_id="audit",
|
||||||
|
kind="section",
|
||||||
|
label="System audit",
|
||||||
|
order=90,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="audit.admin.tenant",
|
||||||
|
module_id="audit",
|
||||||
|
kind="section",
|
||||||
|
label="Tenant audit",
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="audit",
|
module_id="audit",
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(audit_models.AuditLog, audit_models.AuditOutboxEvent, label="Audit"),
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
audit_models.AuditEvidenceBundle,
|
||||||
|
audit_models.AuditLog,
|
||||||
|
audit_models.AuditOutboxDelivery,
|
||||||
|
audit_models.AuditOutboxEvent,
|
||||||
|
label="Audit",
|
||||||
|
),
|
||||||
retirement_notes="Destructive retirement drops audit-owned database tables after the installer captures a database snapshot.",
|
retirement_notes="Destructive retirement drops audit-owned database tables after the installer captures a database snapshot.",
|
||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(audit_models.AuditLog, audit_models.AuditOutboxEvent, label="Audit"),
|
persistent_table_uninstall_guard(
|
||||||
|
audit_models.AuditEvidenceBundle,
|
||||||
|
audit_models.AuditLog,
|
||||||
|
audit_models.AuditOutboxDelivery,
|
||||||
|
audit_models.AuditOutboxEvent,
|
||||||
|
label="Audit",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_AUDIT_RECORDER: _audit_recorder,
|
CAPABILITY_AUDIT_RECORDER: _audit_recorder,
|
||||||
CAPABILITY_AUDIT_RETENTION: _audit_retention,
|
CAPABILITY_AUDIT_RETENTION: _audit_retention,
|
||||||
|
CAPABILITY_PLATFORM_EVENT_OUTBOX: _event_outbox,
|
||||||
|
AUDIT_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
|
capability_documentation={
|
||||||
|
AUDIT_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Audit data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports minimized tenant actor evidence with retain-only "
|
||||||
|
"erasure outcomes."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="governance_accountability",
|
||||||
|
kind="governance",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/AUDIT_TRACE_CONTEXT.md",
|
||||||
|
test_ref="tests/test_audit_module_contract.py",
|
||||||
|
known_limits=(
|
||||||
|
"Cross-deployment long-term archive transfer remains deployment-specific.",
|
||||||
|
),
|
||||||
|
owned_concepts=(
|
||||||
|
"audit record",
|
||||||
|
"audit retention",
|
||||||
|
"audit evidence bundle",
|
||||||
|
"transactional event outbox",
|
||||||
|
),
|
||||||
|
non_owned_concepts=("domain record", "policy decision", "external effect"),
|
||||||
|
recovery_docs=("README.md",),
|
||||||
|
security_docs=("docs/AUDIT_TRACE_CONTEXT.md", "docs/EVIDENCE_BUNDLES.md"),
|
||||||
|
operations_docs=("README.md", "docs/EVIDENCE_BUNDLES.md"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Audit module database migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Development-track Audit migrations."""
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
"""durable platform event delivery ledger
|
||||||
|
|
||||||
|
Revision ID: a8d1e4f7b2c5
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-29 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a8d1e4f7b2c5"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "c91f0a72be34"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "audit_outbox_deliveries" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("outbox_event_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("consumer_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("delivery_key", sa.String(length=300), nullable=False),
|
||||||
|
sa.Column("policy_decision_ref", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("quarantined_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("replay_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("last_replayed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_replayed_by", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("last_replay_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["outbox_event_id"],
|
||||||
|
["audit_outbox_events.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_audit_outbox_deliveries_outbox_event_id_"
|
||||||
|
"audit_outbox_events"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_audit_outbox_deliveries"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"delivery_key",
|
||||||
|
name="uq_audit_outbox_delivery_key",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"outbox_event_id",
|
||||||
|
"consumer_id",
|
||||||
|
name="uq_audit_outbox_delivery_consumer",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_deliveries_outbox_event_id",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["outbox_event_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_deliveries_status",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_delivery_status_next_attempt_at",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["status", "next_attempt_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_delivery_consumer_status",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["consumer_id", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if (
|
||||||
|
"audit_outbox_deliveries"
|
||||||
|
in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
):
|
||||||
|
op.drop_table("audit_outbox_deliveries")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""audit evidence bundle lifecycle
|
||||||
|
|
||||||
|
Revision ID: b9e2f5a8c3d6
|
||||||
|
Revises: a8d1e4f7b2c5
|
||||||
|
Create Date: 2026-08-20 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b9e2f5a8c3d6"
|
||||||
|
down_revision = "a8d1e4f7b2c5"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "audit_evidence_bundles" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("request_payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("bundle_payload", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("bundle_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("record_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("reference_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("error_code", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_evidence_bundles")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundles_tenant_id",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["tenant_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundles_status",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundle_tenant_created_at",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["tenant_id", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundle_status_created_at",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["status", "created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if "audit_evidence_bundles" in sa.inspect(op.get_bind()).get_table_names():
|
||||||
|
op.drop_table("audit_evidence_bundles")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Release-track Audit migrations."""
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
"""durable platform event delivery ledger
|
||||||
|
|
||||||
|
Revision ID: a8d1e4f7b2c5
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-29 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a8d1e4f7b2c5"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "c91f0a72be34"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "audit_outbox_deliveries" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("outbox_event_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("consumer_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("delivery_key", sa.String(length=300), nullable=False),
|
||||||
|
sa.Column("policy_decision_ref", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("quarantined_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("replay_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("last_replayed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_replayed_by", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("last_replay_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["outbox_event_id"],
|
||||||
|
["audit_outbox_events.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_audit_outbox_deliveries_outbox_event_id_"
|
||||||
|
"audit_outbox_events"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_audit_outbox_deliveries"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"delivery_key",
|
||||||
|
name="uq_audit_outbox_delivery_key",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"outbox_event_id",
|
||||||
|
"consumer_id",
|
||||||
|
name="uq_audit_outbox_delivery_consumer",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_deliveries_outbox_event_id",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["outbox_event_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_deliveries_status",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_delivery_status_next_attempt_at",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["status", "next_attempt_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_outbox_delivery_consumer_status",
|
||||||
|
"audit_outbox_deliveries",
|
||||||
|
["consumer_id", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if (
|
||||||
|
"audit_outbox_deliveries"
|
||||||
|
in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
):
|
||||||
|
op.drop_table("audit_outbox_deliveries")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""audit evidence bundle lifecycle
|
||||||
|
|
||||||
|
Revision ID: b9e2f5a8c3d6
|
||||||
|
Revises: a8d1e4f7b2c5
|
||||||
|
Create Date: 2026-08-20 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b9e2f5a8c3d6"
|
||||||
|
down_revision = "a8d1e4f7b2c5"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "audit_evidence_bundles" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("request_payload", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("bundle_payload", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("bundle_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("record_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("reference_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("error_code", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_evidence_bundles")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundles_tenant_id",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["tenant_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundles_status",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundle_tenant_created_at",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["tenant_id", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_audit_evidence_bundle_status_created_at",
|
||||||
|
"audit_evidence_bundles",
|
||||||
|
["status", "created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if "audit_evidence_bundles" in sa.inspect(op.get_bind()).get_table_names():
|
||||||
|
op.drop_table("audit_evidence_bundles")
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import delete, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_audit.backend.db.models import AuditOutboxEvent
|
from govoplan_audit.backend.db.models import (
|
||||||
|
AuditOutboxDelivery,
|
||||||
|
AuditOutboxEvent,
|
||||||
|
)
|
||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
|
DurableEventConsumer,
|
||||||
EventActorRef,
|
EventActorRef,
|
||||||
EventClassification,
|
EventClassification,
|
||||||
EventObjectRef,
|
EventObjectRef,
|
||||||
@@ -17,14 +21,29 @@ from govoplan_core.core.events import (
|
|||||||
ensure_event_trace,
|
ensure_event_trace,
|
||||||
publish_platform_event,
|
publish_platform_event,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
EventDispatcher = Callable[[PlatformEvent], None]
|
EventDispatcher = Callable[[PlatformEvent], None]
|
||||||
|
|
||||||
|
|
||||||
class SqlAuditOutbox:
|
class SqlAuditOutbox:
|
||||||
|
def __init__(self, *, max_attempts: int = 8) -> None:
|
||||||
|
self._max_attempts = max(1, min(int(max_attempts), 100))
|
||||||
|
|
||||||
def enqueue(self, session: object, event: PlatformEvent) -> AuditOutboxEvent:
|
def enqueue(self, session: object, event: PlatformEvent) -> AuditOutboxEvent:
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
traced = ensure_event_trace(event)
|
traced = ensure_event_trace(event)
|
||||||
|
existing = db.scalar(
|
||||||
|
select(AuditOutboxEvent).where(
|
||||||
|
AuditOutboxEvent.event_id == traced.event_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.payload != traced.to_dict():
|
||||||
|
raise ValueError(
|
||||||
|
"A different platform event already uses this event id"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
item = AuditOutboxEvent(
|
item = AuditOutboxEvent(
|
||||||
event_id=traced.event_id,
|
event_id=traced.event_id,
|
||||||
event_type=traced.type,
|
event_type=traced.type,
|
||||||
@@ -36,48 +55,507 @@ class SqlAuditOutbox:
|
|||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.flush()
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def dispatch_pending(
|
def dispatch_pending(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
*,
|
*,
|
||||||
dispatcher: EventDispatcher = publish_platform_event,
|
tenant_id: str | None = None,
|
||||||
|
tenantless_only: bool = False,
|
||||||
|
consumers: Sequence[DurableEventConsumer] = (),
|
||||||
|
observer: EventDispatcher | None = publish_platform_event,
|
||||||
|
dispatcher: EventDispatcher | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
|
if tenant_id is not None and tenantless_only:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenant and tenantless event filters are mutually exclusive"
|
||||||
|
)
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
rows = (
|
consumers_by_id = _consumer_map(consumers)
|
||||||
|
query = (
|
||||||
db.query(AuditOutboxEvent)
|
db.query(AuditOutboxEvent)
|
||||||
.filter(
|
.filter(
|
||||||
AuditOutboxEvent.status.in_(("pending", "failed")),
|
AuditOutboxEvent.status.in_(
|
||||||
|
("pending", "failed", "retrying")
|
||||||
|
),
|
||||||
or_(AuditOutboxEvent.next_attempt_at.is_(None), AuditOutboxEvent.next_attempt_at <= now),
|
or_(AuditOutboxEvent.next_attempt_at.is_(None), AuditOutboxEvent.next_attempt_at <= now),
|
||||||
)
|
)
|
||||||
.order_by(AuditOutboxEvent.created_at.asc(), AuditOutboxEvent.id.asc())
|
)
|
||||||
|
if tenant_id:
|
||||||
|
query = query.filter(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"].as_string()
|
||||||
|
== tenant_id
|
||||||
|
)
|
||||||
|
elif tenantless_only:
|
||||||
|
query = query.filter(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"]
|
||||||
|
.as_string()
|
||||||
|
.is_(None)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
query.order_by(AuditOutboxEvent.created_at.asc(), AuditOutboxEvent.id.asc())
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
.limit(max(1, min(int(limit), 500)))
|
.limit(max(1, min(int(limit), 500)))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
counts = {"selected": len(rows), "dispatched": 0, "failed": 0}
|
counts = {
|
||||||
|
"selected": len(rows),
|
||||||
|
"delivered": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
"dispatched": 0,
|
||||||
|
"observer_failed": 0,
|
||||||
|
}
|
||||||
|
effective_observer = dispatcher or observer
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
event = _event_from_payload(row.payload)
|
||||||
dispatcher(_event_from_payload(row.payload))
|
deliveries = _event_deliveries(
|
||||||
except Exception as exc: # noqa: BLE001 - dispatcher errors must be retained for retry/diagnostics.
|
db,
|
||||||
row.status = "failed"
|
row=row,
|
||||||
row.attempts += 1
|
event=event,
|
||||||
row.last_error = str(exc)
|
consumers=consumers_by_id.values(),
|
||||||
row.next_attempt_at = now + _retry_delay(row.attempts)
|
)
|
||||||
counts["failed"] += 1
|
_dispatch_event_deliveries(
|
||||||
continue
|
event,
|
||||||
row.status = "dispatched"
|
deliveries=deliveries,
|
||||||
row.attempts += 1
|
consumers_by_id=consumers_by_id,
|
||||||
row.dispatched_at = now
|
now=now,
|
||||||
row.next_attempt_at = None
|
max_attempts=self._max_attempts,
|
||||||
row.last_error = None
|
counts=counts,
|
||||||
counts["dispatched"] += 1
|
)
|
||||||
|
_finish_event_dispatch(
|
||||||
|
row,
|
||||||
|
deliveries=deliveries,
|
||||||
|
observer=effective_observer,
|
||||||
|
event=event,
|
||||||
|
now=now,
|
||||||
|
counts=counts,
|
||||||
|
)
|
||||||
db.flush()
|
db.flush()
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
def replay_delivery(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
consumer_id: str,
|
||||||
|
operator_id: str,
|
||||||
|
reason: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
db = _session(session)
|
||||||
|
clean_reason = reason.strip()
|
||||||
|
clean_operator_id = operator_id.strip()
|
||||||
|
if not clean_reason or len(clean_reason) > 2000:
|
||||||
|
raise ValueError(
|
||||||
|
"Replay reason must contain between 1 and 2000 characters"
|
||||||
|
)
|
||||||
|
if not clean_operator_id or len(clean_operator_id) > 128:
|
||||||
|
raise ValueError("Replay operator id is invalid")
|
||||||
|
row = db.scalar(
|
||||||
|
select(AuditOutboxDelivery)
|
||||||
|
.join(
|
||||||
|
AuditOutboxEvent,
|
||||||
|
AuditOutboxEvent.id
|
||||||
|
== AuditOutboxDelivery.outbox_event_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
AuditOutboxEvent.event_id == event_id,
|
||||||
|
AuditOutboxDelivery.consumer_id == consumer_id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise LookupError("Platform event delivery was not found")
|
||||||
|
if row.status not in {"retrying", "quarantined"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Only retrying or quarantined deliveries can be replayed"
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
row.status = "pending"
|
||||||
|
row.attempts = 0
|
||||||
|
row.next_attempt_at = now
|
||||||
|
row.quarantined_at = None
|
||||||
|
row.last_error = None
|
||||||
|
row.replay_count += 1
|
||||||
|
row.last_replayed_at = now
|
||||||
|
row.last_replayed_by = clean_operator_id
|
||||||
|
row.last_replay_reason = clean_reason
|
||||||
|
event_row = db.get(AuditOutboxEvent, row.outbox_event_id)
|
||||||
|
if event_row is None:
|
||||||
|
raise LookupError("Platform event envelope was not found")
|
||||||
|
event_row.status = "pending"
|
||||||
|
event_row.next_attempt_at = now
|
||||||
|
event_row.last_error = None
|
||||||
|
event_row.dispatched_at = None
|
||||||
|
db.flush()
|
||||||
|
return _delivery_state(row, event_id=event_row.event_id)
|
||||||
|
|
||||||
|
def purge_terminal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
tenantless_only: bool = False,
|
||||||
|
before: datetime,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
if tenant_id is not None and tenantless_only:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenant and tenantless event filters are mutually exclusive"
|
||||||
|
)
|
||||||
|
db = _session(session)
|
||||||
|
clauses = [
|
||||||
|
AuditOutboxEvent.status == "dispatched",
|
||||||
|
AuditOutboxEvent.dispatched_at.is_not(None),
|
||||||
|
AuditOutboxEvent.dispatched_at < before,
|
||||||
|
]
|
||||||
|
if tenant_id:
|
||||||
|
clauses.append(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"].as_string()
|
||||||
|
== tenant_id
|
||||||
|
)
|
||||||
|
elif tenantless_only:
|
||||||
|
clauses.append(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"]
|
||||||
|
.as_string()
|
||||||
|
.is_(None)
|
||||||
|
)
|
||||||
|
ids = tuple(
|
||||||
|
db.scalars(
|
||||||
|
select(AuditOutboxEvent.id)
|
||||||
|
.where(*clauses)
|
||||||
|
.order_by(
|
||||||
|
AuditOutboxEvent.dispatched_at,
|
||||||
|
AuditOutboxEvent.id,
|
||||||
|
)
|
||||||
|
.limit(max(1, min(int(limit), 5000)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ids:
|
||||||
|
db.execute(
|
||||||
|
delete(AuditOutboxEvent).where(
|
||||||
|
AuditOutboxEvent.id.in_(ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
return {"deleted": len(ids)}
|
||||||
|
|
||||||
|
def delivery_metrics(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
db = _session(session)
|
||||||
|
event_counts = {
|
||||||
|
str(status): int(count)
|
||||||
|
for status, count in db.execute(
|
||||||
|
select(
|
||||||
|
AuditOutboxEvent.status,
|
||||||
|
func.count(AuditOutboxEvent.id),
|
||||||
|
).group_by(AuditOutboxEvent.status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delivery_counts = {
|
||||||
|
str(status): int(count)
|
||||||
|
for status, count in db.execute(
|
||||||
|
select(
|
||||||
|
AuditOutboxDelivery.status,
|
||||||
|
func.count(AuditOutboxDelivery.id),
|
||||||
|
).group_by(AuditOutboxDelivery.status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
consumer_counts = {
|
||||||
|
str(consumer_id): {
|
||||||
|
str(status): int(count)
|
||||||
|
for status, count in values
|
||||||
|
}
|
||||||
|
for consumer_id, values in _consumer_delivery_counts(db).items()
|
||||||
|
}
|
||||||
|
oldest_due = db.scalar(
|
||||||
|
select(func.min(AuditOutboxDelivery.created_at)).where(
|
||||||
|
AuditOutboxDelivery.status.in_(
|
||||||
|
("pending", "retrying")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"events": event_counts,
|
||||||
|
"deliveries": delivery_counts,
|
||||||
|
"consumers": consumer_counts,
|
||||||
|
"oldest_due_at": (
|
||||||
|
oldest_due.isoformat()
|
||||||
|
if isinstance(oldest_due, datetime)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _consumer_map(
|
||||||
|
consumers: Sequence[DurableEventConsumer],
|
||||||
|
) -> dict[str, DurableEventConsumer]:
|
||||||
|
result: dict[str, DurableEventConsumer] = {}
|
||||||
|
for consumer in consumers:
|
||||||
|
if consumer.consumer_id in result:
|
||||||
|
raise ValueError(
|
||||||
|
f"Duplicate durable event consumer: {consumer.consumer_id}"
|
||||||
|
)
|
||||||
|
result[consumer.consumer_id] = consumer
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _event_deliveries(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
row: AuditOutboxEvent,
|
||||||
|
event: PlatformEvent,
|
||||||
|
consumers: Sequence[DurableEventConsumer],
|
||||||
|
) -> list[AuditOutboxDelivery]:
|
||||||
|
existing = {
|
||||||
|
delivery.consumer_id: delivery
|
||||||
|
for delivery in session.scalars(
|
||||||
|
select(AuditOutboxDelivery).where(
|
||||||
|
AuditOutboxDelivery.outbox_event_id == row.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for consumer in consumers:
|
||||||
|
if (
|
||||||
|
consumer.consumer_id in existing
|
||||||
|
or not consumer.accepts(event)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
delivery = AuditOutboxDelivery(
|
||||||
|
outbox_event_id=row.id,
|
||||||
|
consumer_id=consumer.consumer_id,
|
||||||
|
delivery_key=consumer.delivery_key(event),
|
||||||
|
policy_decision_ref=consumer.policy_decision_ref,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
session.add(delivery)
|
||||||
|
existing[consumer.consumer_id] = delivery
|
||||||
|
session.flush()
|
||||||
|
return sorted(
|
||||||
|
existing.values(),
|
||||||
|
key=lambda item: (item.created_at, item.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_event_deliveries(
|
||||||
|
event: PlatformEvent,
|
||||||
|
*,
|
||||||
|
deliveries: Sequence[AuditOutboxDelivery],
|
||||||
|
consumers_by_id: Mapping[str, DurableEventConsumer],
|
||||||
|
now: datetime,
|
||||||
|
max_attempts: int,
|
||||||
|
counts: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
for delivery in deliveries:
|
||||||
|
if not _delivery_is_due(delivery, now=now):
|
||||||
|
continue
|
||||||
|
consumer = consumers_by_id.get(delivery.consumer_id)
|
||||||
|
if consumer is None:
|
||||||
|
_record_delivery_failure(
|
||||||
|
delivery,
|
||||||
|
error="Durable event consumer is not registered",
|
||||||
|
now=now,
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
counts=counts,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not consumer.accepts(event):
|
||||||
|
_quarantine_delivery(
|
||||||
|
delivery,
|
||||||
|
error=(
|
||||||
|
"The current durable subscription no longer permits "
|
||||||
|
"this event"
|
||||||
|
),
|
||||||
|
now=now,
|
||||||
|
counts=counts,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
event.classification in {"confidential", "restricted"}
|
||||||
|
and delivery.policy_decision_ref
|
||||||
|
!= consumer.policy_decision_ref
|
||||||
|
):
|
||||||
|
_quarantine_delivery(
|
||||||
|
delivery,
|
||||||
|
error=(
|
||||||
|
"The policy decision for this classified event "
|
||||||
|
"subscription changed"
|
||||||
|
),
|
||||||
|
now=now,
|
||||||
|
counts=counts,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
consumer.handler(event, delivery.delivery_key)
|
||||||
|
except Exception as exc: # noqa: BLE001 - failures must be persisted.
|
||||||
|
_record_delivery_failure(
|
||||||
|
delivery,
|
||||||
|
error=str(exc),
|
||||||
|
now=now,
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
counts=counts,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
delivery.status = "delivered"
|
||||||
|
delivery.attempts += 1
|
||||||
|
delivery.delivered_at = now
|
||||||
|
delivery.next_attempt_at = None
|
||||||
|
delivery.quarantined_at = None
|
||||||
|
delivery.last_error = None
|
||||||
|
counts["delivered"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_is_due(
|
||||||
|
delivery: AuditOutboxDelivery,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
if delivery.status not in {"pending", "retrying"}:
|
||||||
|
return False
|
||||||
|
if delivery.next_attempt_at is None:
|
||||||
|
return True
|
||||||
|
return _as_utc(delivery.next_attempt_at) <= now
|
||||||
|
|
||||||
|
|
||||||
|
def _record_delivery_failure(
|
||||||
|
delivery: AuditOutboxDelivery,
|
||||||
|
*,
|
||||||
|
error: str,
|
||||||
|
now: datetime,
|
||||||
|
max_attempts: int,
|
||||||
|
counts: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
delivery.attempts += 1
|
||||||
|
delivery.last_error = _bounded_error(error)
|
||||||
|
if delivery.attempts >= max_attempts:
|
||||||
|
_quarantine_delivery(
|
||||||
|
delivery,
|
||||||
|
error=delivery.last_error,
|
||||||
|
now=now,
|
||||||
|
counts=counts,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
delivery.status = "retrying"
|
||||||
|
delivery.next_attempt_at = now + _retry_delay(delivery.attempts)
|
||||||
|
counts["retrying"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _quarantine_delivery(
|
||||||
|
delivery: AuditOutboxDelivery,
|
||||||
|
*,
|
||||||
|
error: str,
|
||||||
|
now: datetime,
|
||||||
|
counts: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
delivery.status = "quarantined"
|
||||||
|
delivery.quarantined_at = now
|
||||||
|
delivery.next_attempt_at = None
|
||||||
|
delivery.last_error = _bounded_error(error)
|
||||||
|
counts["quarantined"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_event_dispatch(
|
||||||
|
row: AuditOutboxEvent,
|
||||||
|
*,
|
||||||
|
deliveries: Sequence[AuditOutboxDelivery],
|
||||||
|
observer: EventDispatcher | None,
|
||||||
|
event: PlatformEvent,
|
||||||
|
now: datetime,
|
||||||
|
counts: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
row.attempts += 1
|
||||||
|
quarantined = [
|
||||||
|
item for item in deliveries
|
||||||
|
if item.status == "quarantined"
|
||||||
|
]
|
||||||
|
outstanding = [
|
||||||
|
item for item in deliveries
|
||||||
|
if item.status in {"pending", "retrying"}
|
||||||
|
]
|
||||||
|
if quarantined:
|
||||||
|
row.status = "quarantined"
|
||||||
|
row.next_attempt_at = None
|
||||||
|
row.last_error = quarantined[0].last_error
|
||||||
|
return
|
||||||
|
if outstanding:
|
||||||
|
row.status = "retrying"
|
||||||
|
due_times = [
|
||||||
|
item.next_attempt_at
|
||||||
|
for item in outstanding
|
||||||
|
if item.next_attempt_at is not None
|
||||||
|
]
|
||||||
|
row.next_attempt_at = min(due_times) if due_times else now
|
||||||
|
row.last_error = next(
|
||||||
|
(
|
||||||
|
item.last_error
|
||||||
|
for item in outstanding
|
||||||
|
if item.last_error
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if observer is not None:
|
||||||
|
try:
|
||||||
|
observer(event)
|
||||||
|
except Exception as exc: # noqa: BLE001 - observers are non-durable.
|
||||||
|
counts["observer_failed"] += 1
|
||||||
|
row.last_error = _bounded_error(
|
||||||
|
f"Non-durable observer failed: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.last_error = None
|
||||||
|
else:
|
||||||
|
row.last_error = None
|
||||||
|
row.status = "dispatched"
|
||||||
|
row.dispatched_at = now
|
||||||
|
row.next_attempt_at = None
|
||||||
|
counts["dispatched"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_state(
|
||||||
|
delivery: AuditOutboxDelivery,
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"event_id": event_id,
|
||||||
|
"consumer_id": delivery.consumer_id,
|
||||||
|
"delivery_key": delivery.delivery_key,
|
||||||
|
"status": delivery.status,
|
||||||
|
"attempts": delivery.attempts,
|
||||||
|
"replay_count": delivery.replay_count,
|
||||||
|
"last_replayed_at": delivery.last_replayed_at,
|
||||||
|
"last_replayed_by": delivery.last_replayed_by,
|
||||||
|
"last_replay_reason": delivery.last_replay_reason,
|
||||||
|
"last_error": delivery.last_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _consumer_delivery_counts(
|
||||||
|
session: Session,
|
||||||
|
) -> dict[str, list[tuple[str, int]]]:
|
||||||
|
result: dict[str, list[tuple[str, int]]] = {}
|
||||||
|
for consumer_id, status, count in session.execute(
|
||||||
|
select(
|
||||||
|
AuditOutboxDelivery.consumer_id,
|
||||||
|
AuditOutboxDelivery.status,
|
||||||
|
func.count(AuditOutboxDelivery.id),
|
||||||
|
).group_by(
|
||||||
|
AuditOutboxDelivery.consumer_id,
|
||||||
|
AuditOutboxDelivery.status,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
result.setdefault(str(consumer_id), []).append(
|
||||||
|
(str(status), int(count))
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def enqueue_platform_event(session: object, event: PlatformEvent) -> AuditOutboxEvent:
|
def enqueue_platform_event(session: object, event: PlatformEvent) -> AuditOutboxEvent:
|
||||||
return SqlAuditOutbox().enqueue(session, event)
|
return SqlAuditOutbox().enqueue(session, event)
|
||||||
@@ -97,6 +575,17 @@ def _retry_delay(attempts: int) -> timedelta:
|
|||||||
return timedelta(seconds=seconds)
|
return timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_error(value: str) -> str:
|
||||||
|
clean = value.strip() or "Unknown durable event delivery failure"
|
||||||
|
return clean[:4000]
|
||||||
|
|
||||||
|
|
||||||
def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
|
def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
|
||||||
return PlatformEvent(
|
return PlatformEvent(
|
||||||
type=str(payload["type"]),
|
type=str(payload["type"]),
|
||||||
@@ -111,6 +600,19 @@ def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
|
|||||||
subject=_object_ref(payload.get("subject")),
|
subject=_object_ref(payload.get("subject")),
|
||||||
resource=_object_ref(payload.get("resource")),
|
resource=_object_ref(payload.get("resource")),
|
||||||
classification=cast(EventClassification, str(payload.get("classification") or "internal")),
|
classification=cast(EventClassification, str(payload.get("classification") or "internal")),
|
||||||
|
institutional_context=_institutional_context(
|
||||||
|
payload.get("institutional_context")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _institutional_context(
|
||||||
|
value: object,
|
||||||
|
) -> GovernedContextEnvelope | None:
|
||||||
|
return (
|
||||||
|
GovernedContextEnvelope.from_mapping(value)
|
||||||
|
if isinstance(value, Mapping)
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import PermissionDefinition, RoleTemplate
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_EVIDENCE_EXPORT_SCOPE = "audit:evidence:export"
|
||||||
|
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE = "audit:system_evidence:export"
|
||||||
|
|
||||||
|
AUDIT_PERMISSIONS = (
|
||||||
|
PermissionDefinition(
|
||||||
|
scope=AUDIT_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
module_id="audit",
|
||||||
|
resource="evidence",
|
||||||
|
action="export",
|
||||||
|
label="Export tenant audit evidence",
|
||||||
|
description="Generate and download bounded evidence bundles for the active tenant.",
|
||||||
|
category="Audit",
|
||||||
|
level="tenant",
|
||||||
|
),
|
||||||
|
PermissionDefinition(
|
||||||
|
scope=AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
|
||||||
|
module_id="audit",
|
||||||
|
resource="system_evidence",
|
||||||
|
action="export",
|
||||||
|
label="Export system audit evidence",
|
||||||
|
description="Generate and download system-wide or cross-tenant audit evidence bundles.",
|
||||||
|
category="Audit",
|
||||||
|
level="system",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
AUDIT_ROLE_TEMPLATES = (
|
||||||
|
RoleTemplate(
|
||||||
|
slug="audit_evidence_exporter",
|
||||||
|
name="Audit evidence exporter",
|
||||||
|
description="Export independently verifiable evidence bundles for the active tenant; audit read access remains separately assignable.",
|
||||||
|
permissions=(AUDIT_EVIDENCE_EXPORT_SCOPE,),
|
||||||
|
level="tenant",
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="audit_system_evidence_exporter",
|
||||||
|
name="System audit evidence exporter",
|
||||||
|
description="Export cross-tenant evidence bundles; system audit read access remains separately assignable.",
|
||||||
|
permissions=(AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,),
|
||||||
|
level="system",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AUDIT_EVIDENCE_EXPORT_SCOPE",
|
||||||
|
"AUDIT_PERMISSIONS",
|
||||||
|
"AUDIT_ROLE_TEMPLATES",
|
||||||
|
"AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE",
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from govoplan_audit.backend.evidence_verifier import verify_evidence_bundle
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Verify a GovOPlaN Audit evidence bundle without a source database.",
|
||||||
|
)
|
||||||
|
parser.add_argument("bundle", type=Path, help="Downloaded evidence-bundle JSON file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--trusted-key",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="KEY_ID=BASE64_PUBLIC_KEY",
|
||||||
|
help="Trusted Ed25519 public key; may be repeated.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--external",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="REFERENCE_ID=PATH",
|
||||||
|
help="External evidence file corresponding to a manifest reference; may be repeated.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--pretty", action="store_true", help="Indent verification JSON output")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(args.bundle.read_text(encoding="utf-8"))
|
||||||
|
trusted_keys = _key_values(args.trusted_key, label="trusted key")
|
||||||
|
external_paths = _key_values(args.external, label="external evidence")
|
||||||
|
external = {reference_id: Path(path).read_bytes() for reference_id, path in external_paths.items()}
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
result = verify_evidence_bundle(
|
||||||
|
payload,
|
||||||
|
trusted_keys=trusted_keys,
|
||||||
|
external_evidence=external,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
result,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2 if args.pretty else None,
|
||||||
|
separators=None if args.pretty else (",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0 if result["status"] == "valid" else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _key_values(values: list[str], *, label: str) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for value in values:
|
||||||
|
key, separator, item = value.partition("=")
|
||||||
|
if not separator or not key.strip() or not item.strip():
|
||||||
|
raise ValueError(f"Invalid {label}; expected KEY=VALUE.")
|
||||||
|
if key in result:
|
||||||
|
raise ValueError(f"Duplicate {label} id: {key}.")
|
||||||
|
result[key] = item
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - console entry point
|
||||||
|
raise SystemExit(main())
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"bundle_id": "fixture-bundle-v1",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"kind": "audit_record",
|
||||||
|
"path": "records/0",
|
||||||
|
"sha256": "4d1acd7f7bb9a4a74c88c43485a0fcfde7c25ae086745ae18fa94107ad5bd3d9"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generated_at": "2026-08-20T10:00:00Z",
|
||||||
|
"manifest_sha256": "6c6eac07c422e14936a5abddfe999e1d7346371284b5faa111ddaea0399e538b",
|
||||||
|
"record_count": 1,
|
||||||
|
"redaction": {
|
||||||
|
"profile": "bounded-v1",
|
||||||
|
"prohibited_fields": [
|
||||||
|
"authorization",
|
||||||
|
"body",
|
||||||
|
"content_bytes",
|
||||||
|
"credential",
|
||||||
|
"credentials",
|
||||||
|
"file_content",
|
||||||
|
"file_contents",
|
||||||
|
"message",
|
||||||
|
"message_body",
|
||||||
|
"password",
|
||||||
|
"payload",
|
||||||
|
"raw_message",
|
||||||
|
"recipient_list",
|
||||||
|
"recipients",
|
||||||
|
"secret",
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"raw_evidence_embedded": false
|
||||||
|
},
|
||||||
|
"reference_count": 0,
|
||||||
|
"request": {
|
||||||
|
"record_ids": [
|
||||||
|
"fixture-audit-1"
|
||||||
|
],
|
||||||
|
"selection_complete": true
|
||||||
|
},
|
||||||
|
"schema": "govoplan.audit.evidence-bundle",
|
||||||
|
"scope": {
|
||||||
|
"kind": "tenant",
|
||||||
|
"tenant_id": "tenant-fixture"
|
||||||
|
},
|
||||||
|
"signatures": [],
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"action": "fixture.recorded",
|
||||||
|
"actor": {
|
||||||
|
"api_key_id": null,
|
||||||
|
"user_id": null
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"correlation_id": "fixture-trace",
|
||||||
|
"result": "accepted"
|
||||||
|
},
|
||||||
|
"object": {
|
||||||
|
"id": "fixture-1",
|
||||||
|
"type": "fixture"
|
||||||
|
},
|
||||||
|
"policy_source_provenance": {},
|
||||||
|
"record_id": "fixture-audit-1",
|
||||||
|
"recorded_at": "2026-08-20T10:00:00Z",
|
||||||
|
"redaction": {
|
||||||
|
"profile": "bounded-v1",
|
||||||
|
"redacted_paths": []
|
||||||
|
},
|
||||||
|
"scope": "tenant",
|
||||||
|
"tenant_id": "tenant-fixture",
|
||||||
|
"trace_context": {
|
||||||
|
"correlation_id": "fixture-trace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"references": [],
|
||||||
|
"schema": "govoplan.audit.evidence-bundle",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
+351
-14
@@ -1,14 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_audit.backend.commands import AuditCommand, CommandBus
|
from govoplan_audit.backend.commands import AuditCommand, CommandBus
|
||||||
from govoplan_audit.backend.db.models import AuditOutboxEvent
|
from govoplan_audit.backend.db.models import (
|
||||||
|
AuditOutboxDelivery,
|
||||||
|
AuditOutboxEvent,
|
||||||
|
)
|
||||||
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
||||||
from govoplan_core.core.events import EventActorRef, PlatformEvent
|
from govoplan_core.core.events import (
|
||||||
|
DurableEventConsumer,
|
||||||
|
EventActorRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -30,12 +44,23 @@ class AuditCommandBusTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class AuditOutboxTests(unittest.TestCase):
|
class AuditOutboxTests(unittest.TestCase):
|
||||||
def test_outbox_enqueues_governed_event_and_dispatches_pending_rows(self) -> None:
|
def _database(self):
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
|
self.addCleanup(engine.dispose)
|
||||||
Session = sessionmaker(bind=engine)
|
Base.metadata.create_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
AuditOutboxEvent.__table__,
|
||||||
|
AuditOutboxDelivery.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
def test_outbox_enqueues_governed_event_and_dispatches_pending_rows(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
outbox = SqlAuditOutbox()
|
outbox = SqlAuditOutbox()
|
||||||
seen: list[PlatformEvent] = []
|
seen: list[PlatformEvent] = []
|
||||||
|
observed: list[PlatformEvent] = []
|
||||||
|
|
||||||
with Session() as session:
|
with Session() as session:
|
||||||
event = PlatformEvent(
|
event = PlatformEvent(
|
||||||
@@ -52,31 +77,343 @@ class AuditOutboxTests(unittest.TestCase):
|
|||||||
self.assertEqual(event.event_id, row.correlation_id)
|
self.assertEqual(event.event_id, row.correlation_id)
|
||||||
self.assertEqual("user-1", row.payload["actor"]["id"])
|
self.assertEqual("user-1", row.payload["actor"]["id"])
|
||||||
|
|
||||||
counts = outbox.dispatch_pending(session, dispatcher=seen.append)
|
counts = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.consumer.v1",
|
||||||
|
event_types=frozenset({"tenant.created"}),
|
||||||
|
handler=lambda delivered, _key: seen.append(
|
||||||
|
delivered
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observer=observed.append,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual({"selected": 1, "dispatched": 1, "failed": 0}, counts)
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"selected": 1,
|
||||||
|
"delivered": 1,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
"dispatched": 1,
|
||||||
|
"observer_failed": 0,
|
||||||
|
},
|
||||||
|
counts,
|
||||||
|
)
|
||||||
self.assertEqual(1, len(seen))
|
self.assertEqual(1, len(seen))
|
||||||
|
self.assertEqual(1, len(observed))
|
||||||
self.assertEqual("tenant.created", seen[0].type)
|
self.assertEqual("tenant.created", seen[0].type)
|
||||||
self.assertEqual("dispatched", row.status)
|
self.assertEqual("dispatched", row.status)
|
||||||
self.assertEqual(1, row.attempts)
|
self.assertEqual(1, row.attempts)
|
||||||
self.assertIsNotNone(row.dispatched_at)
|
self.assertIsNotNone(row.dispatched_at)
|
||||||
|
delivery = session.query(AuditOutboxDelivery).one()
|
||||||
|
self.assertEqual("delivered", delivery.status)
|
||||||
|
self.assertEqual(
|
||||||
|
f"{event.event_id}:tests.consumer.v1",
|
||||||
|
delivery.delivery_key,
|
||||||
|
)
|
||||||
|
|
||||||
def test_outbox_records_failed_dispatch_for_retry(self) -> None:
|
def test_dispatch_partitions_pending_events_by_tenant(self) -> None:
|
||||||
engine = create_engine("sqlite:///:memory:")
|
Session = self._database()
|
||||||
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
|
|
||||||
Session = sessionmaker(bind=engine)
|
|
||||||
outbox = SqlAuditOutbox()
|
outbox = SqlAuditOutbox()
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
first = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="files.file.created",
|
||||||
|
module_id="files",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="files.file.created",
|
||||||
|
module_id="files",
|
||||||
|
tenant=EventTenantRef(id="tenant-2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counts = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.tenant-filter.v1",
|
||||||
|
handler=lambda event, _key: seen.append(
|
||||||
|
event.tenant.id if event.tenant else "system"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, counts["selected"])
|
||||||
|
self.assertEqual(["tenant-1"], seen)
|
||||||
|
self.assertEqual("dispatched", first.status)
|
||||||
|
self.assertEqual("pending", second.status)
|
||||||
|
|
||||||
|
def test_dispatch_can_select_only_tenantless_system_events(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox()
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
system = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(type="system.ready", module_id="core"),
|
||||||
|
)
|
||||||
|
tenant = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="tenant.ready",
|
||||||
|
module_id="tenancy",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counts = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
tenantless_only=True,
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.system-filter.v1",
|
||||||
|
handler=lambda event, _key: seen.append(event.type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, counts["selected"])
|
||||||
|
self.assertEqual(["system.ready"], seen)
|
||||||
|
self.assertEqual("dispatched", system.status)
|
||||||
|
self.assertEqual("pending", tenant.status)
|
||||||
|
|
||||||
|
def test_outbox_preserves_institutional_context(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox()
|
||||||
|
seen: list[PlatformEvent] = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
context = GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(revision="decision:7", recorded_at=now),
|
||||||
|
decision_ref=InstitutionalReference(
|
||||||
|
kind="decision",
|
||||||
|
owner_module="committee",
|
||||||
|
object_id="decision-7",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="7",
|
||||||
|
valid_at=now,
|
||||||
|
),
|
||||||
|
approval_refs=(
|
||||||
|
InstitutionalReference(
|
||||||
|
kind="approval",
|
||||||
|
owner_module="workflow",
|
||||||
|
object_id="approval-3",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
valid_at=now,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="committee.decision.recorded",
|
||||||
|
module_id="committee",
|
||||||
|
institutional_context=context,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.institutional-context.v1",
|
||||||
|
event_types=frozenset({"committee.decision.recorded"}),
|
||||||
|
handler=lambda delivered, _key: seen.append(delivered),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("decision-7", seen[0].institutional_context.decision_ref.object_id)
|
||||||
|
self.assertEqual(
|
||||||
|
"approval-3",
|
||||||
|
seen[0].institutional_context.approval_refs[0].object_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_outbox_retries_then_quarantines_a_failed_consumer(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox(max_attempts=2)
|
||||||
|
consumer = DurableEventConsumer(
|
||||||
|
consumer_id="tests.failing.v1",
|
||||||
|
handler=lambda _event, _key: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("offline")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
with Session() as session:
|
with Session() as session:
|
||||||
row = outbox.enqueue(session, PlatformEvent(type="demo.failed", module_id="audit"))
|
row = outbox.enqueue(session, PlatformEvent(type="demo.failed", module_id="audit"))
|
||||||
|
|
||||||
counts = outbox.dispatch_pending(session, dispatcher=lambda event: (_ for _ in ()).throw(RuntimeError("offline")))
|
counts = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(consumer,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual({"selected": 1, "dispatched": 0, "failed": 1}, counts)
|
self.assertEqual(1, counts["retrying"])
|
||||||
self.assertEqual("failed", row.status)
|
self.assertEqual("retrying", row.status)
|
||||||
self.assertEqual(1, row.attempts)
|
self.assertEqual(1, row.attempts)
|
||||||
self.assertEqual("offline", row.last_error)
|
self.assertEqual("offline", row.last_error)
|
||||||
self.assertIsNotNone(row.next_attempt_at)
|
self.assertIsNotNone(row.next_attempt_at)
|
||||||
|
delivery = session.query(AuditOutboxDelivery).one()
|
||||||
|
delivery.next_attempt_at = None
|
||||||
|
row.next_attempt_at = None
|
||||||
|
|
||||||
|
second = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(consumer,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, second["quarantined"])
|
||||||
|
self.assertEqual("quarantined", row.status)
|
||||||
|
self.assertEqual("quarantined", delivery.status)
|
||||||
|
self.assertIsNotNone(delivery.quarantined_at)
|
||||||
|
self.assertIsNone(delivery.next_attempt_at)
|
||||||
|
|
||||||
|
def test_replay_keeps_a_stable_delivery_key_and_runs_once(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox(max_attempts=1)
|
||||||
|
event = PlatformEvent(type="demo.replay", module_id="audit")
|
||||||
|
failing = DurableEventConsumer(
|
||||||
|
consumer_id="tests.replay.v1",
|
||||||
|
handler=lambda _event, _key: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("offline")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
delivered: list[str] = []
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
outbox.enqueue(session, event)
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(failing,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
state = outbox.replay_delivery(
|
||||||
|
session,
|
||||||
|
event_id=event.event_id,
|
||||||
|
consumer_id=failing.consumer_id,
|
||||||
|
operator_id="operator-1",
|
||||||
|
reason="Dependency recovered",
|
||||||
|
)
|
||||||
|
self.assertEqual("pending", state["status"])
|
||||||
|
self.assertEqual(1, state["replay_count"])
|
||||||
|
expected_key = f"{event.event_id}:{failing.consumer_id}"
|
||||||
|
self.assertEqual(expected_key, state["delivery_key"])
|
||||||
|
|
||||||
|
healthy = DurableEventConsumer(
|
||||||
|
consumer_id=failing.consumer_id,
|
||||||
|
handler=lambda _event, key: delivered.append(key),
|
||||||
|
)
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(healthy,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
replay = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(healthy,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([expected_key], delivered)
|
||||||
|
self.assertEqual(0, replay["selected"])
|
||||||
|
|
||||||
|
def test_classified_subscription_requires_and_persists_policy_decision(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "policy decision"):
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.restricted.v1",
|
||||||
|
classifications=frozenset({"restricted"}),
|
||||||
|
handler=lambda _event, _key: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox()
|
||||||
|
consumer = DurableEventConsumer(
|
||||||
|
consumer_id="tests.restricted.v1",
|
||||||
|
classifications=frozenset({"restricted"}),
|
||||||
|
policy_decision_ref="policy-decision:42",
|
||||||
|
handler=lambda _event, _key: None,
|
||||||
|
)
|
||||||
|
with Session() as session:
|
||||||
|
outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="case.changed",
|
||||||
|
module_id="cases",
|
||||||
|
classification="restricted",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(consumer,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = session.query(AuditOutboxDelivery).one()
|
||||||
|
self.assertEqual(
|
||||||
|
"policy-decision:42",
|
||||||
|
delivery.policy_decision_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_metrics_and_retention_keep_quarantined_evidence(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox(max_attempts=1)
|
||||||
|
with Session() as session:
|
||||||
|
delivered_event = PlatformEvent(
|
||||||
|
type="demo.delivered",
|
||||||
|
module_id="audit",
|
||||||
|
)
|
||||||
|
failed_event = PlatformEvent(
|
||||||
|
type="demo.failed",
|
||||||
|
module_id="audit",
|
||||||
|
)
|
||||||
|
delivered_row = outbox.enqueue(session, delivered_event)
|
||||||
|
outbox.enqueue(session, failed_event)
|
||||||
|
consumer = DurableEventConsumer(
|
||||||
|
consumer_id="tests.metrics.v1",
|
||||||
|
handler=lambda event, _key: (
|
||||||
|
(_ for _ in ()).throw(RuntimeError("offline"))
|
||||||
|
if event.type == "demo.failed"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(consumer,),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
delivered_row.dispatched_at = (
|
||||||
|
datetime.now(timezone.utc) - timedelta(days=100)
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = outbox.delivery_metrics(session)
|
||||||
|
purged = outbox.purge_terminal(
|
||||||
|
session,
|
||||||
|
before=datetime.now(timezone.utc)
|
||||||
|
- timedelta(days=90),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, metrics["events"]["dispatched"])
|
||||||
|
self.assertEqual(1, metrics["events"]["quarantined"])
|
||||||
|
self.assertEqual(1, purged["deleted"])
|
||||||
|
remaining = session.query(AuditOutboxEvent).one()
|
||||||
|
self.assertEqual("quarantined", remaining.status)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import pathlib
|
|||||||
import tomllib
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_audit.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ class AuditModuleContractTests(unittest.TestCase):
|
|||||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
dependencies = tuple(project["dependencies"])
|
dependencies = tuple(project["dependencies"])
|
||||||
|
|
||||||
self.assertIn("govoplan-core>=0.1.6", dependencies)
|
self.assertTrue(any(item.startswith("govoplan-core>=") for item in dependencies))
|
||||||
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
|
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
|
||||||
|
|
||||||
def test_audit_source_does_not_import_access_implementation(self) -> None:
|
def test_audit_source_does_not_import_access_implementation(self) -> None:
|
||||||
@@ -25,6 +27,43 @@ class AuditModuleContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual([], offenders)
|
self.assertEqual([], offenders)
|
||||||
|
|
||||||
|
def test_audit_documentation_declares_admin_help_contexts(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||||
|
|
||||||
|
evidence = topics["audit.read-authorized-evidence"]
|
||||||
|
self.assertIn("audit.admin.system", evidence.metadata["help_contexts"])
|
||||||
|
self.assertIn("audit.admin.tenant", evidence.metadata["help_contexts"])
|
||||||
|
self.assertEqual(
|
||||||
|
["audit.admin.system", "audit.admin.tenant"],
|
||||||
|
evidence.metadata["surfaces"],
|
||||||
|
)
|
||||||
|
operations = topics["audit.recording-retention-and-outbox"]
|
||||||
|
self.assertIn("audit.retention", operations.metadata["help_contexts"])
|
||||||
|
|
||||||
|
def test_public_documentation_has_complete_german_baseline(self) -> None:
|
||||||
|
for topic in get_manifest().documentation:
|
||||||
|
german = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(german.get(field) for field in ("title", "summary", "body")),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_evidence_export_permissions_are_module_owned_and_scope_separated(self) -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
permissions = {item.scope: item for item in manifest.permissions}
|
||||||
|
|
||||||
|
self.assertEqual("tenant", permissions["audit:evidence:export"].level)
|
||||||
|
self.assertEqual("system", permissions["audit:system_evidence:export"].level)
|
||||||
|
roles = {item.slug: item for item in manifest.role_templates}
|
||||||
|
self.assertIn(
|
||||||
|
"audit:evidence:export",
|
||||||
|
roles["audit_evidence_exporter"].permissions,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"audit:system_evidence:export",
|
||||||
|
roles["audit_system_evidence_exporter"].permissions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db.models import (
|
||||||
|
AuditEvidenceBundle,
|
||||||
|
AuditLog,
|
||||||
|
AuditOutboxDelivery,
|
||||||
|
AuditOutboxEvent,
|
||||||
|
)
|
||||||
|
from govoplan_audit.backend.dsar_provider import (
|
||||||
|
AUDIT_DSAR_CAPABILITY,
|
||||||
|
AuditDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_audit.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
DataSubjectRequest,
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: AuditDsarProvider, *, active: bool = True) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (AUDIT_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "audit"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("audit",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "audit"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != AUDIT_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
if "access_users" not in Base.metadata.tables:
|
||||||
|
Table(
|
||||||
|
"access_users",
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
if "access_api_keys" not in Base.metadata.tables:
|
||||||
|
Table(
|
||||||
|
"access_api_keys",
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
Base.metadata.tables["access_users"],
|
||||||
|
Base.metadata.tables["access_api_keys"],
|
||||||
|
AuditLog.__table__,
|
||||||
|
AuditOutboxEvent.__table__,
|
||||||
|
AuditOutboxDelivery.__table__,
|
||||||
|
AuditEvidenceBundle.__table__,
|
||||||
|
DataSubjectRequest.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = AuditDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_payload(
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"type": "case.decision.recorded",
|
||||||
|
"module_id": "cases",
|
||||||
|
"payload": {
|
||||||
|
"authorization_token": "event-secret-do-not-export",
|
||||||
|
"private_case_data": "third-party-data-do-not-export",
|
||||||
|
},
|
||||||
|
"occurred_at": "2026-08-21T10:00:00+00:00",
|
||||||
|
"event_id": event_id,
|
||||||
|
"correlation_id": f"trace-{event_id}",
|
||||||
|
"causation_id": None,
|
||||||
|
"actor": {"type": "user", "id": actor_id, "label": "Private name"},
|
||||||
|
"tenant": {"id": tenant_id, "label": "Private tenant label"},
|
||||||
|
"subject": {"type": "case", "id": "case-1", "label": "Private subject"},
|
||||||
|
"resource": {
|
||||||
|
"type": "decision",
|
||||||
|
"id": "decision-1",
|
||||||
|
"label": "Private resource",
|
||||||
|
},
|
||||||
|
"classification": "confidential",
|
||||||
|
"institutional_context": {
|
||||||
|
"authorization": "institutional-secret-do-not-export"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
event = AuditOutboxEvent(
|
||||||
|
id="outbox-1",
|
||||||
|
event_id="event-1",
|
||||||
|
event_type="case.decision.recorded",
|
||||||
|
module_id="cases",
|
||||||
|
correlation_id="trace-event-1",
|
||||||
|
causation_id=None,
|
||||||
|
classification="confidential",
|
||||||
|
payload=self._event_payload(
|
||||||
|
event_id="event-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
),
|
||||||
|
status="dispatched",
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
AuditLog(
|
||||||
|
id="audit-log-1",
|
||||||
|
scope="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
action="case.decision.recorded",
|
||||||
|
object_type="case",
|
||||||
|
object_id="case-1",
|
||||||
|
details={
|
||||||
|
"correlation_id": "trace-audit-1",
|
||||||
|
"policy_decision_ref": "policy:decision-1",
|
||||||
|
"source_ref": "cases:case-1:v4",
|
||||||
|
"message_body": "audit-secret-do-not-export",
|
||||||
|
"authorization_token": "audit-token-do-not-export",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AuditLog(
|
||||||
|
id="audit-log-other",
|
||||||
|
scope="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-other",
|
||||||
|
action="other.action",
|
||||||
|
object_type="case",
|
||||||
|
object_id="case-other",
|
||||||
|
details={"private": "other actor"},
|
||||||
|
),
|
||||||
|
AuditLog(
|
||||||
|
id="audit-log-other-tenant",
|
||||||
|
scope="tenant",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
user_id="user-1",
|
||||||
|
action="other.tenant.action",
|
||||||
|
object_type="case",
|
||||||
|
object_id="case-other-tenant",
|
||||||
|
details={"private": "other tenant"},
|
||||||
|
),
|
||||||
|
event,
|
||||||
|
AuditOutboxDelivery(
|
||||||
|
id="delivery-1",
|
||||||
|
outbox_event_id="outbox-1",
|
||||||
|
consumer_id="reporting.audit-consumer",
|
||||||
|
delivery_key="event-1:reporting.audit-consumer",
|
||||||
|
policy_decision_ref="policy:delivery-1",
|
||||||
|
status="delivered",
|
||||||
|
attempts=1,
|
||||||
|
replay_count=1,
|
||||||
|
last_replayed_by="user-1",
|
||||||
|
last_replay_reason="private-replay-reason-do-not-export",
|
||||||
|
last_error="private-delivery-error-do-not-export",
|
||||||
|
),
|
||||||
|
AuditEvidenceBundle(
|
||||||
|
id="bundle-1",
|
||||||
|
scope="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requested_by="user-1",
|
||||||
|
status="ready",
|
||||||
|
request_payload={"secret": "request-secret-do-not-export"},
|
||||||
|
bundle_payload={"secret": "bundle-secret-do-not-export"},
|
||||||
|
bundle_sha256="a" * 64,
|
||||||
|
record_count=1,
|
||||||
|
reference_count=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subject() -> DsarSubjectRef:
|
||||||
|
return DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
external_references={"audit.user": "user-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_is_tenant_actor_scoped_and_minimized(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self._subject(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"audit_actor_record",
|
||||||
|
"audit_event_actor_record",
|
||||||
|
"audit_replay_attribution",
|
||||||
|
"audit_evidence_bundle_attribution",
|
||||||
|
],
|
||||||
|
[record.resource_type for record in records],
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertIn("trace-audit-1", exported)
|
||||||
|
self.assertIn("policy:decision-1", exported)
|
||||||
|
self.assertIn("decision-1", exported)
|
||||||
|
self.assertIn("a" * 64, exported)
|
||||||
|
for excluded in (
|
||||||
|
"audit-secret-do-not-export",
|
||||||
|
"audit-token-do-not-export",
|
||||||
|
"event-secret-do-not-export",
|
||||||
|
"third-party-data-do-not-export",
|
||||||
|
"institutional-secret-do-not-export",
|
||||||
|
"private-replay-reason-do-not-export",
|
||||||
|
"private-delivery-error-do-not-export",
|
||||||
|
"request-secret-do-not-export",
|
||||||
|
"bundle-secret-do-not-export",
|
||||||
|
"audit-log-other",
|
||||||
|
"audit-log-other-tenant",
|
||||||
|
):
|
||||||
|
self.assertNotIn(excluded, exported)
|
||||||
|
|
||||||
|
def test_exact_references_narrow_and_alias_conflicts_fail_closed(self) -> None:
|
||||||
|
log = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"audit.user": "user-1",
|
||||||
|
"audit.log": "audit-log-1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"audit.user": "user-1",
|
||||||
|
"audit.event": "event-1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"audit.account": "account-other"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reference_only = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={"audit.log": "audit-log-1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["audit-log-1"], [item.resource_id for item in log])
|
||||||
|
self.assertEqual(["outbox-1"], [item.resource_id for item in event])
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), reference_only)
|
||||||
|
|
||||||
|
def test_erasure_is_retain_only_and_execution_is_blocked(self) -> None:
|
||||||
|
subject = self._subject()
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertTrue(actions)
|
||||||
|
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||||
|
self.assertTrue(all(not action.executable for action in actions))
|
||||||
|
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||||
|
self.assertIsNotNone(self.session.get(AuditLog, "audit-log-1"))
|
||||||
|
self.assertIsNotNone(self.session.get(AuditOutboxEvent, "outbox-1"))
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = self._subject()
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="audit_actor_record",
|
||||||
|
resource_id="audit-log-1",
|
||||||
|
category="evidence",
|
||||||
|
title="Foreign evidence",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="cases:retain:audit:audit-log-1",
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
kind="retain",
|
||||||
|
resource_type="audit_actor_record",
|
||||||
|
resource_id="audit-log-1",
|
||||||
|
title="Retain evidence",
|
||||||
|
rationale="Evidence",
|
||||||
|
executable=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_and_manifest_register_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-AUDIT-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self._subject(),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([AUDIT_DSAR_CAPABILITY], row.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(4, row.search_result["record_count"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-AUDIT-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self._subject(),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[AUDIT_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(AUDIT_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(AUDIT_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||||
|
self.assertIn(
|
||||||
|
AUDIT_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "audit.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db.models import AuditEvidenceBundle, AuditLog
|
||||||
|
from govoplan_audit.backend.evidence_bundles import (
|
||||||
|
EvidenceBundleError,
|
||||||
|
build_evidence_bundle,
|
||||||
|
canonical_sha256,
|
||||||
|
sanitize_audit_details,
|
||||||
|
)
|
||||||
|
from govoplan_audit.backend.evidence_verifier import verify_evidence_bundle
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).with_name("fixtures")
|
||||||
|
NOW = datetime(2026, 8, 20, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _record() -> AuditLog:
|
||||||
|
item = AuditLog(
|
||||||
|
id="audit-1",
|
||||||
|
scope="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
api_key_id=None,
|
||||||
|
action="case.decision.recorded",
|
||||||
|
object_type="case",
|
||||||
|
object_id="case-42",
|
||||||
|
details={
|
||||||
|
"correlation_id": "trace-42",
|
||||||
|
"policy_decision_ref": "policy:42",
|
||||||
|
"source_ref": "cases:case-42:v7",
|
||||||
|
"recipient_count": 2,
|
||||||
|
"recipients": ["one@example.test", "two@example.test"],
|
||||||
|
"message_body": "restricted body",
|
||||||
|
"api_token": "secret-token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
item.created_at = NOW
|
||||||
|
item.updated_at = NOW
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(*, references=(), signing_key_id=None, signing_private_key_path=None):
|
||||||
|
return build_evidence_bundle(
|
||||||
|
[_record()],
|
||||||
|
bundle_id="bundle-1",
|
||||||
|
generated_at=NOW,
|
||||||
|
scope={"kind": "tenant", "tenant_id": "tenant-1"},
|
||||||
|
request={"record_ids": ["audit-1"], "selection_complete": True},
|
||||||
|
references=references,
|
||||||
|
signing_key_id=signing_key_id,
|
||||||
|
signing_private_key_path=signing_private_key_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_is_deterministic_and_redacts_prohibited_evidence() -> None:
|
||||||
|
first = _bundle()
|
||||||
|
second = _bundle()
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert canonical_sha256(first) == canonical_sha256(second)
|
||||||
|
details = first["records"][0]["details"]
|
||||||
|
assert details["recipient_count"] == 2
|
||||||
|
assert details["recipients"] == {"redacted": True, "item_count": 2}
|
||||||
|
assert details["message_body"] == {"redacted": True}
|
||||||
|
assert details["api_token"] == {"redacted": True}
|
||||||
|
encoded = json.dumps(first, sort_keys=True)
|
||||||
|
assert "one@example.test" not in encoded
|
||||||
|
assert "restricted body" not in encoded
|
||||||
|
assert "secret-token" not in encoded
|
||||||
|
assert verify_evidence_bundle(first)["status"] == "valid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitizer_bounds_non_json_values_and_oversized_details() -> None:
|
||||||
|
sanitized, paths = sanitize_audit_details({"custom": object(), "body": "secret"})
|
||||||
|
|
||||||
|
assert sanitized["custom"]["redacted"] is True
|
||||||
|
assert sanitized["body"] == {"redacted": True}
|
||||||
|
assert paths == ["details.body", "details.custom"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_locators_cannot_embed_credentials_or_tokens() -> None:
|
||||||
|
reference = {
|
||||||
|
"reference_id": "decision-42",
|
||||||
|
"kind": "module_evidence",
|
||||||
|
"owner_module": "decisions",
|
||||||
|
"locator": "https://user:password@example.test/evidence",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_bundle(references=[reference])
|
||||||
|
except EvidenceBundleError as exc:
|
||||||
|
assert "appears to contain a secret" in str(exc)
|
||||||
|
else: # pragma: no cover - explicit fail-closed assertion
|
||||||
|
raise AssertionError("Secret-bearing evidence locators must be rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_distinguishes_missing_unverifiable_and_tampered_evidence() -> None:
|
||||||
|
artifact = b"canonical external evidence"
|
||||||
|
reference = {
|
||||||
|
"reference_id": "decision-42",
|
||||||
|
"kind": "module_evidence",
|
||||||
|
"owner_module": "decisions",
|
||||||
|
"locator": "decisions:decision-42:v3",
|
||||||
|
"content_sha256": hashlib.sha256(artifact).hexdigest(),
|
||||||
|
"required": True,
|
||||||
|
}
|
||||||
|
bundle = _bundle(references=[reference])
|
||||||
|
|
||||||
|
missing = verify_evidence_bundle(bundle)
|
||||||
|
assert missing["status"] == "incomplete"
|
||||||
|
assert missing["errors"][0]["code"] == "external_evidence_missing"
|
||||||
|
|
||||||
|
valid = verify_evidence_bundle(
|
||||||
|
bundle,
|
||||||
|
external_evidence={"decision-42": artifact},
|
||||||
|
)
|
||||||
|
assert valid["status"] == "valid"
|
||||||
|
|
||||||
|
mismatched = verify_evidence_bundle(
|
||||||
|
bundle,
|
||||||
|
external_evidence={"decision-42": b"changed"},
|
||||||
|
)
|
||||||
|
assert mismatched["status"] == "tampered"
|
||||||
|
assert mismatched["errors"][0]["code"] == "external_evidence_hash_mismatch"
|
||||||
|
|
||||||
|
unverifiable_reference = dict(reference)
|
||||||
|
unverifiable_reference["content_sha256"] = None
|
||||||
|
unverifiable = verify_evidence_bundle(_bundle(references=[unverifiable_reference]))
|
||||||
|
assert unverifiable["status"] == "unverifiable"
|
||||||
|
assert unverifiable["warnings"][1]["code"] == "reference_unverifiable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_detects_record_and_manifest_tampering() -> None:
|
||||||
|
bundle = _bundle()
|
||||||
|
changed = copy.deepcopy(bundle)
|
||||||
|
changed["records"][0]["action"] = "case.decision.deleted"
|
||||||
|
|
||||||
|
result = verify_evidence_bundle(changed)
|
||||||
|
|
||||||
|
assert result["status"] == "tampered"
|
||||||
|
assert {item["code"] for item in result["errors"]} == {"item_hash_mismatch"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_ed25519_signature_verifies_offline(tmp_path: Path) -> None:
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
private_path = tmp_path / "audit-evidence.pem"
|
||||||
|
private_path.write_bytes(
|
||||||
|
private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
public_key = base64.b64encode(
|
||||||
|
private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
).decode("ascii")
|
||||||
|
bundle = _bundle(
|
||||||
|
signing_key_id="institution-2026",
|
||||||
|
signing_private_key_path=private_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
trusted = verify_evidence_bundle(
|
||||||
|
bundle,
|
||||||
|
trusted_keys={"institution-2026": public_key},
|
||||||
|
)
|
||||||
|
untrusted = verify_evidence_bundle(bundle)
|
||||||
|
|
||||||
|
assert trusted["status"] == "valid"
|
||||||
|
assert trusted["signature_state"] == "trusted"
|
||||||
|
assert untrusted["status"] == "unverifiable"
|
||||||
|
assert untrusted["signature_state"] == "unverifiable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_version_is_reported_without_guessing() -> None:
|
||||||
|
bundle = _bundle()
|
||||||
|
bundle["version"] = "2.0"
|
||||||
|
|
||||||
|
result = verify_evidence_bundle(bundle)
|
||||||
|
|
||||||
|
assert result["status"] == "unsupported"
|
||||||
|
assert result["errors"][0]["code"] == "unsupported_version"
|
||||||
|
|
||||||
|
|
||||||
|
def test_supported_v1_compatibility_fixture_verifies_deterministically() -> None:
|
||||||
|
payload = json.loads((FIXTURES / "evidence_bundle_v1.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
first = verify_evidence_bundle(payload)
|
||||||
|
second = verify_evidence_bundle(payload)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert first["status"] == "valid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_bundle_lifecycle_is_persisted() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine, tables=[AuditEvidenceBundle.__table__])
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
with Session() as session:
|
||||||
|
row = AuditEvidenceBundle(
|
||||||
|
id="bundle-1",
|
||||||
|
scope="tenant",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requested_by="operator-1",
|
||||||
|
status="pending",
|
||||||
|
request_payload={"record_ids": ["audit-1"]},
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
assert row.status == "pending"
|
||||||
|
|
||||||
|
row.status = "ready"
|
||||||
|
row.bundle_payload = _bundle()
|
||||||
|
row.bundle_sha256 = canonical_sha256(row.bundle_payload)
|
||||||
|
row.record_count = 1
|
||||||
|
row.generated_at = NOW
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
restored = session.get(AuditEvidenceBundle, "bundle-1")
|
||||||
|
assert restored is not None
|
||||||
|
assert restored.status == "ready"
|
||||||
|
assert restored.record_count == 1
|
||||||
|
assert restored.bundle_payload["version"] == "1.0"
|
||||||
|
engine.dispose()
|
||||||
+8
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/audit-webui",
|
"name": "@govoplan/audit-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -12,12 +12,15 @@
|
|||||||
"import": "./src/index.ts"
|
"import": "./src/index.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const panel = readFileSync(
|
||||||
|
fileURLToPath(new URL("../src/features/audit/AdminAuditPanel.tsx", import.meta.url)),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const moduleSource = readFileSync(
|
||||||
|
fileURLToPath(new URL("../src/module.ts", import.meta.url)),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(panel, /AdminPageLayout,[\s\S]*DataGrid,[\s\S]*Dialog,[\s\S]*DocumentationHelpLink,[\s\S]*TableActionGroup/);
|
||||||
|
assert.match(panel, /topicId: "audit\.read-authorized-evidence"/);
|
||||||
|
assert.match(panel, /disabledReason=\{loading \? I18N\.loading : undefined\}/);
|
||||||
|
assert.match(panel, /pagination=\{\{[\s\S]*mode: "server"/);
|
||||||
|
assert.match(panel, /id="admin-audit-event-details-grid"/);
|
||||||
|
assert.match(panel, /auditDetailRows\(selected\?\.details \?\? \{\}\)/);
|
||||||
|
assert.match(panel, /emptyText=\{I18N\.noRecords\}/);
|
||||||
|
assert.doesNotMatch(panel, /<pre|window\.(?:alert|confirm)\(/);
|
||||||
|
assert.doesNotMatch(panel, /@govoplan\/(?:access|admin)-webui|govoplan_(?:access|admin)/);
|
||||||
|
|
||||||
|
assert.match(moduleSource, /generatedTranslations/);
|
||||||
|
assert.match(moduleSource, /translations,/);
|
||||||
|
assert.match(moduleSource, /surfaceId: "audit\.admin\.system"[\s\S]*allOf: \["system:audit:read"\]/);
|
||||||
|
assert.match(moduleSource, /surfaceId: "audit\.admin\.tenant"[\s\S]*allOf: \["audit:read"\]/);
|
||||||
|
|
||||||
|
console.log("Audit administration surfaces satisfy the monitoring and evidence pattern contracts.");
|
||||||
@@ -45,6 +45,30 @@ export type AuditAdminDeltaResponse = AuditAdminListResponse & {
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EvidenceBundleExportRequest = {
|
||||||
|
scope: "tenant" | "system" | "all";
|
||||||
|
tenant_id?: string | null;
|
||||||
|
record_ids?: string[];
|
||||||
|
max_records?: number;
|
||||||
|
sign?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EvidenceBundleResponse = {
|
||||||
|
id: string;
|
||||||
|
scope: "tenant" | "system" | "all";
|
||||||
|
tenant_id?: string | null;
|
||||||
|
status: "pending" | "ready" | "failed";
|
||||||
|
bundle_sha256?: string | null;
|
||||||
|
record_count: number;
|
||||||
|
reference_count: number;
|
||||||
|
generated_at?: string | null;
|
||||||
|
download_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EvidenceBundleDownloadResponse = {
|
||||||
|
bundle: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
function auditQuery(options: AuditQueryOptions & { since?: string | null } = {}): string {
|
function auditQuery(options: AuditQueryOptions & { since?: string | null } = {}): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||||
@@ -72,3 +96,20 @@ export function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOption
|
|||||||
export function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
|
export function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
|
||||||
return apiFetch(settings, `/api/v1/admin/audit/delta${auditQuery(options)}`);
|
return apiFetch(settings, `/api/v1/admin/audit/delta${auditQuery(options)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportAuditEvidenceBundle(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: EvidenceBundleExportRequest
|
||||||
|
): Promise<EvidenceBundleResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/audit/evidence-bundles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadAuditEvidenceBundle(
|
||||||
|
settings: ApiSettings,
|
||||||
|
bundleId: string
|
||||||
|
): Promise<EvidenceBundleDownloadResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/audit/evidence-bundles/${bundleId}/download`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,69 @@
|
|||||||
|
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AdminIconButton,
|
|
||||||
AdminPageLayout,
|
AdminPageLayout,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
Button,
|
Button,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
formatAdminDateTime as formatDateTime,
|
formatAdminDateTime as formatDateTime,
|
||||||
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
mergeDeltaRows,
|
mergeDeltaRows,
|
||||||
|
TableActionGroup,
|
||||||
useDeltaWatermarks,
|
useDeltaWatermarks,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type DataGridQueryState
|
type DataGridQueryState
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem, type AuditSortBy } from "../../api/audit";
|
import {
|
||||||
|
downloadAuditEvidenceBundle,
|
||||||
|
exportAuditEvidenceBundle,
|
||||||
|
fetchAdminAudit,
|
||||||
|
fetchAdminAuditDelta,
|
||||||
|
type AuditAdminItem,
|
||||||
|
type AuditSortBy
|
||||||
|
} from "../../api/audit";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
systemMode?: boolean;
|
systemMode?: boolean;
|
||||||
};
|
};
|
||||||
|
type AuditDetailRow = {
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
actionLabel: "i18n:govoplan-audit.action.f1a20801",
|
||||||
|
actions: "i18n:govoplan-audit.actions.f1a20802",
|
||||||
|
actor: "i18n:govoplan-audit.actor.f1a20803",
|
||||||
|
close: "i18n:govoplan-audit.close.f1a20804",
|
||||||
|
details: "i18n:govoplan-audit.details.f1a20805",
|
||||||
|
eventDetails: "i18n:govoplan-audit.audit_event_details.f1a20806",
|
||||||
|
exportEvidence: "i18n:govoplan-audit.export_page_evidence.f1a20822",
|
||||||
|
exportingEvidence: "i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823",
|
||||||
|
inspect: "i18n:govoplan-audit.inspect_audit_event.f1a20807",
|
||||||
|
loading: "i18n:govoplan-audit.audit_evidence_is_loading.f1a20808",
|
||||||
|
noDetails: "i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809",
|
||||||
|
noRecords: "i18n:govoplan-audit.no_audit_records_match_the_current_scope_and_filters.f1a20810",
|
||||||
|
object: "i18n:govoplan-audit.object.f1a20811",
|
||||||
|
reload: "i18n:govoplan-audit.reload_audit_evidence.f1a20812",
|
||||||
|
scopeLabel: "i18n:govoplan-audit.scope.f1a20813",
|
||||||
|
system: "i18n:govoplan-audit.system.f1a20814",
|
||||||
|
systemAudit: "i18n:govoplan-audit.system_audit.f1a20815",
|
||||||
|
systemDescription: "i18n:govoplan-audit.system_level_administrative_history_showing_value0_value1_of_value2.f1a20816",
|
||||||
|
tenantAudit: "i18n:govoplan-audit.tenant_audit.f1a20817",
|
||||||
|
tenantContext: "i18n:govoplan-audit.tenant_context.f1a20818",
|
||||||
|
tenantDescription: "i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819",
|
||||||
|
time: "i18n:govoplan-audit.time.f1a20820",
|
||||||
|
value: "i18n:govoplan-audit.value.f1a20821"
|
||||||
|
} as const;
|
||||||
|
|
||||||
const DEFAULT_QUERY: DataGridQueryState = {
|
const DEFAULT_QUERY: DataGridQueryState = {
|
||||||
sort: { columnId: "time", direction: "desc" },
|
sort: { columnId: "time", direction: "desc" },
|
||||||
@@ -42,7 +84,12 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [reloadToken, setReloadToken] = useState(0);
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||||
|
const canExport = hasScope(
|
||||||
|
auth,
|
||||||
|
systemMode ? "audit:system_evidence:export" : "audit:evidence:export"
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -112,29 +159,81 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const exportPageEvidence = useCallback(async () => {
|
||||||
|
setExporting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await exportAuditEvidenceBundle(settings, {
|
||||||
|
scope: systemMode ? "system" : "tenant",
|
||||||
|
tenant_id: systemMode ? null : tenantId,
|
||||||
|
record_ids: items.map((item) => item.id),
|
||||||
|
max_records: Math.max(1, items.length),
|
||||||
|
sign: false
|
||||||
|
});
|
||||||
|
const downloaded = await downloadAuditEvidenceBundle(settings, created.id);
|
||||||
|
downloadJson(
|
||||||
|
downloaded.bundle,
|
||||||
|
`govoplan-audit-evidence-${created.id}.json`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, [items, settings, systemMode, tenantId]);
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
|
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
|
||||||
{ id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
{ id: "time", header: I18N.time, width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
||||||
{ id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
|
{ id: "actor", header: I18N.actor, width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System", render: (row) => row.actor_email || I18N.system },
|
||||||
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
{ id: "action", header: I18N.actionLabel, width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
||||||
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "-"} ${row.object_id || ""}`.trim() },
|
{ id: "object", header: I18N.object, width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "-"} ${row.object_id || ""}`.trim() },
|
||||||
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "-" }] : []),
|
...(systemMode ? [{ id: "tenant", header: I18N.tenantContext, width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "-" }] : []),
|
||||||
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
|
{ id: "actions", header: I18N.actions, width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "inspect", label: I18N.inspect, icon: <Search aria-hidden="true" size={16} />, onClick: () => setSelected(row) }]} /> }
|
||||||
], [systemMode]);
|
], [systemMode]);
|
||||||
|
|
||||||
|
const detailColumns = useMemo<DataGridColumn<AuditDetailRow>[]>(() => [
|
||||||
|
{ id: "field", header: I18N.details, minWidth: 180, resizable: true, value: (row) => row.field },
|
||||||
|
{ id: "value", header: I18N.value, minWidth: 260, resizable: true, fill: true, value: (row) => row.value }
|
||||||
|
], []);
|
||||||
|
const detailRows = useMemo(() => auditDetailRows(selected?.details ?? {}), [selected]);
|
||||||
|
|
||||||
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
const lastShown = Math.min(total, page * pageSize);
|
const lastShown = Math.min(total, page * pageSize);
|
||||||
const pageDescription = systemMode
|
const pageDescription = i18nMessage(
|
||||||
? `System-level administrative history, showing ${firstShown}-${lastShown} of ${total}.`
|
systemMode ? I18N.systemDescription : I18N.tenantDescription,
|
||||||
: `Tenant-level administrative history for the active tenant, showing ${firstShown}-${lastShown} of ${total}.`;
|
{ value0: firstShown, value1: lastShown, value2: total }
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title={systemMode ? "System audit" : "Tenant audit"}
|
title={systemMode ? I18N.systemAudit : I18N.tenantAudit}
|
||||||
description={pageDescription}
|
description={pageDescription}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}>
|
actions={(
|
||||||
|
<>
|
||||||
|
{canExport && (
|
||||||
|
<Button
|
||||||
|
onClick={() => { void exportPageEvidence(); }}
|
||||||
|
disabled={loading || exporting || items.length === 0}
|
||||||
|
disabledReason={exporting ? I18N.exportingEvidence : undefined}>
|
||||||
|
{I18N.exportEvidence}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
topicId: "audit.read-authorized-evidence",
|
||||||
|
documentationType: "user"
|
||||||
|
}} />
|
||||||
|
<Button
|
||||||
|
onClick={() => setReloadToken((value) => value + 1)}
|
||||||
|
disabled={loading}
|
||||||
|
disabledReason={loading ? I18N.loading : undefined}>
|
||||||
|
{I18N.reload}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}>
|
||||||
<div className="admin-table-surface">
|
<div className="admin-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={systemMode ? "admin-system-audit-v6" : "admin-tenant-audit-v6"}
|
id={systemMode ? "admin-system-audit-v6" : "admin-tenant-audit-v6"}
|
||||||
@@ -142,7 +241,7 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
initialFit="container"
|
initialFit="container"
|
||||||
getRowKey={(row) => row.id}
|
getRowKey={(row) => row.id}
|
||||||
emptyText="No administrative audit records found."
|
emptyText={I18N.noRecords}
|
||||||
className="admin-audit-grid"
|
className="admin-audit-grid"
|
||||||
initialSort={{ columnId: "time", direction: "desc" }}
|
initialSort={{ columnId: "time", direction: "desc" }}
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -159,18 +258,30 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
|
<Dialog variant="administration" size="wide"
|
||||||
|
open={Boolean(selected)}
|
||||||
|
title={I18N.eventDetails}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
className=""
|
||||||
|
footer={<Button onClick={() => setSelected(null)}>{I18N.close}</Button>}>
|
||||||
{selected && (
|
{selected && (
|
||||||
<>
|
<>
|
||||||
<dl className="admin-details-grid">
|
<DescriptionList>
|
||||||
<div><dt>Scope</dt><dd>{selected.scope}</dd></div>
|
<DescriptionItem term={<>{I18N.scopeLabel}</>}>{selected.scope}</DescriptionItem>
|
||||||
<div><dt>Action</dt><dd>{selected.action}</dd></div>
|
<DescriptionItem term={<>{I18N.actionLabel}</>}>{selected.action}</DescriptionItem>
|
||||||
<div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div>
|
<DescriptionItem term={<>{I18N.actor}</>}>{selected.actor_email || I18N.system}</DescriptionItem>
|
||||||
<div><dt>Object</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
|
<DescriptionItem term={<>{I18N.object}</>}>{selected.object_type || "-"} {selected.object_id || ""}</DescriptionItem>
|
||||||
<div><dt>Tenant context</dt><dd>{selected.tenant_id || "-"}</dd></div>
|
<DescriptionItem term={<>{I18N.tenantContext}</>}>{selected.tenant_id || "-"}</DescriptionItem>
|
||||||
<div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
|
<DescriptionItem term={<>{I18N.time}</>}>{formatDateTime(selected.created_at)}</DescriptionItem>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre>
|
{detailRows.length ? (
|
||||||
|
<DataGrid
|
||||||
|
id="admin-audit-event-details-grid"
|
||||||
|
rows={detailRows}
|
||||||
|
columns={detailColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
initialFit="container" />
|
||||||
|
) : <p className="muted">{I18N.noDetails}</p>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -178,6 +289,33 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadJson(value: unknown, filename: string): void {
|
||||||
|
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditDetailRows(details: Record<string, unknown>): AuditDetailRow[] {
|
||||||
|
return Object.entries(details)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([field, value]) => ({
|
||||||
|
id: field,
|
||||||
|
field,
|
||||||
|
value: auditDetailValue(value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditDetailValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
|
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
|
||||||
return (left, right) => {
|
return (left, right) => {
|
||||||
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
|
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
en: {
|
||||||
|
"i18n:govoplan-audit.action.f1a20801": "Action",
|
||||||
|
"i18n:govoplan-audit.actions.f1a20802": "Actions",
|
||||||
|
"i18n:govoplan-audit.actor.f1a20803": "Actor",
|
||||||
|
"i18n:govoplan-audit.close.f1a20804": "Close",
|
||||||
|
"i18n:govoplan-audit.details.f1a20805": "Detail",
|
||||||
|
"i18n:govoplan-audit.audit_event_details.f1a20806": "Audit event details",
|
||||||
|
"i18n:govoplan-audit.inspect_audit_event.f1a20807": "Inspect audit event",
|
||||||
|
"i18n:govoplan-audit.audit_evidence_is_loading.f1a20808": "Audit evidence is loading.",
|
||||||
|
"i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809": "No additional details were recorded.",
|
||||||
|
"i18n:govoplan-audit.no_audit_records_match_the_current_scope_and_filters.f1a20810": "No audit records match the current scope and filters.",
|
||||||
|
"i18n:govoplan-audit.object.f1a20811": "Object",
|
||||||
|
"i18n:govoplan-audit.reload_audit_evidence.f1a20812": "Reload audit evidence",
|
||||||
|
"i18n:govoplan-audit.scope.f1a20813": "Scope",
|
||||||
|
"i18n:govoplan-audit.system.f1a20814": "System",
|
||||||
|
"i18n:govoplan-audit.system_audit.f1a20815": "System audit",
|
||||||
|
"i18n:govoplan-audit.system_level_administrative_history_showing_value0_value1_of_value2.f1a20816": "System-level administrative history, showing {value0}-{value1} of {value2}.",
|
||||||
|
"i18n:govoplan-audit.tenant_audit.f1a20817": "Tenant audit",
|
||||||
|
"i18n:govoplan-audit.tenant_context.f1a20818": "Tenant context",
|
||||||
|
"i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Tenant-level administrative history for the active tenant, showing {value0}-{value1} of {value2}.",
|
||||||
|
"i18n:govoplan-audit.time.f1a20820": "Time",
|
||||||
|
"i18n:govoplan-audit.value.f1a20821": "Value",
|
||||||
|
"i18n:govoplan-audit.export_page_evidence.f1a20822": "Export page evidence",
|
||||||
|
"i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823": "Audit evidence export is in progress."
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"i18n:govoplan-audit.action.f1a20801": "Aktion",
|
||||||
|
"i18n:govoplan-audit.actions.f1a20802": "Aktionen",
|
||||||
|
"i18n:govoplan-audit.actor.f1a20803": "Akteur",
|
||||||
|
"i18n:govoplan-audit.close.f1a20804": "Schließen",
|
||||||
|
"i18n:govoplan-audit.details.f1a20805": "Detail",
|
||||||
|
"i18n:govoplan-audit.audit_event_details.f1a20806": "Details des Auditereignisses",
|
||||||
|
"i18n:govoplan-audit.inspect_audit_event.f1a20807": "Auditereignis prüfen",
|
||||||
|
"i18n:govoplan-audit.audit_evidence_is_loading.f1a20808": "Auditnachweise werden geladen.",
|
||||||
|
"i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809": "Es wurden keine zusätzlichen Details aufgezeichnet.",
|
||||||
|
"i18n:govoplan-audit.no_audit_records_match_the_current_scope_and_filters.f1a20810": "Keine Auditaufzeichnungen entsprechen dem aktuellen Bereich und den Filtern.",
|
||||||
|
"i18n:govoplan-audit.object.f1a20811": "Objekt",
|
||||||
|
"i18n:govoplan-audit.reload_audit_evidence.f1a20812": "Auditnachweise neu laden",
|
||||||
|
"i18n:govoplan-audit.scope.f1a20813": "Geltungsbereich",
|
||||||
|
"i18n:govoplan-audit.system.f1a20814": "System",
|
||||||
|
"i18n:govoplan-audit.system_audit.f1a20815": "Systemaudit",
|
||||||
|
"i18n:govoplan-audit.system_level_administrative_history_showing_value0_value1_of_value2.f1a20816": "Systemweite administrative Historie, angezeigt werden {value0}-{value1} von {value2}.",
|
||||||
|
"i18n:govoplan-audit.tenant_audit.f1a20817": "Mandantenaudit",
|
||||||
|
"i18n:govoplan-audit.tenant_context.f1a20818": "Mandantenkontext",
|
||||||
|
"i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Administrative Historie des aktiven Mandanten, angezeigt werden {value0}-{value1} von {value2}.",
|
||||||
|
"i18n:govoplan-audit.time.f1a20820": "Zeit",
|
||||||
|
"i18n:govoplan-audit.value.f1a20821": "Wert",
|
||||||
|
"i18n:govoplan-audit.export_page_evidence.f1a20822": "Seitennachweise exportieren",
|
||||||
|
"i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823": "Der Export der Auditnachweise läuft."
|
||||||
|
}
|
||||||
|
};
|
||||||
+19
-3
@@ -1,13 +1,21 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import { type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
import { type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
|
||||||
const AdminAuditPanel = lazy(() => import("./features/audit/AdminAuditPanel"));
|
const AdminAuditPanel = lazy(() => import("./features/audit/AdminAuditPanel"));
|
||||||
|
const translations = {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
};
|
||||||
|
|
||||||
const auditAdminSections: AdminSectionsUiCapability = {
|
const auditAdminSections: AdminSectionsUiCapability = {
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "system-audit",
|
id: "system-audit",
|
||||||
label: "Audit",
|
moduleId: "audit",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "audit.admin.system",
|
||||||
|
label: "i18n:govoplan-audit.system_audit.f1a20815",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 90,
|
order: 90,
|
||||||
allOf: ["system:audit:read"],
|
allOf: ["system:audit:read"],
|
||||||
@@ -19,7 +27,10 @@ const auditAdminSections: AdminSectionsUiCapability = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tenant-audit",
|
id: "tenant-audit",
|
||||||
label: "Audit",
|
moduleId: "audit",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "audit.admin.tenant",
|
||||||
|
label: "i18n:govoplan-audit.tenant_audit.f1a20817",
|
||||||
group: "TENANT",
|
group: "TENANT",
|
||||||
order: 100,
|
order: 100,
|
||||||
allOf: ["audit:read"],
|
allOf: ["audit:read"],
|
||||||
@@ -35,8 +46,13 @@ const auditAdminSections: AdminSectionsUiCapability = {
|
|||||||
export const auditModule: PlatformWebModule = {
|
export const auditModule: PlatformWebModule = {
|
||||||
id: "audit",
|
id: "audit",
|
||||||
label: "Audit",
|
label: "Audit",
|
||||||
version: "0.1.6",
|
version: "0.1.8",
|
||||||
dependencies: ["access", "admin"],
|
dependencies: ["access", "admin"],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "audit.admin.system", moduleId: "audit", kind: "section", label: "System audit", order: 90 },
|
||||||
|
{ id: "audit.admin.tenant", moduleId: "audit", kind: "section", label: "Tenant audit", order: 100 }
|
||||||
|
],
|
||||||
|
translations,
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"admin.sections": auditAdminSections
|
"admin.sections": auditAdminSections
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user