Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f095eb562 | ||
|
|
0cbb249d74 | ||
|
|
f0d2916d46 | ||
|
|
c1e6c15866 | ||
|
|
451361cc05 | ||
|
|
6d3fcc1572 | ||
|
|
4177287b22 | ||
|
|
57ceef0173 | ||
|
|
078e9144b1 | ||
|
|
f74e8cf85b | ||
|
|
0167ab752a | ||
|
|
1479946729 | ||
|
|
130f738970 | ||
|
|
a34935da02 | ||
|
|
86b20c65cb | ||
|
|
a96dc228b8 | ||
|
|
5e4f84a789 | ||
|
|
d3d2c60d7d |
@@ -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.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# GovOPlaN Audit
|
# GovOPlaN Audit
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-audit` owns audit API route contributions and audit administration
|
`govoplan-audit` owns audit API route contributions and audit administration
|
||||||
WebUI sections during the GovOPlaN module split.
|
WebUI sections during the GovOPlaN module split.
|
||||||
|
|
||||||
@@ -22,3 +26,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,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.7",
|
"version": "0.1.15",
|
||||||
"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.7",
|
"@govoplan/core-webui": "^0.1.15",
|
||||||
"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": {
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-audit"
|
name = "govoplan-audit"
|
||||||
version = "0.1.7"
|
version = "0.1.15"
|
||||||
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.7",
|
"govoplan-core>=0.1.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -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,38 @@ 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 .schemas import (
|
||||||
|
AuditAdminDeltaResponse,
|
||||||
|
AuditAdminItem,
|
||||||
|
AuditAdminListResponse,
|
||||||
|
AuditLogItemResponse,
|
||||||
|
AuditLogListResponse,
|
||||||
|
EventDeliveryMetricsResponse,
|
||||||
|
EventDeliveryReplayRequest,
|
||||||
|
EventDeliveryReplayResponse,
|
||||||
|
)
|
||||||
|
|
||||||
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 +212,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 +348,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 +392,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 +411,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 +429,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 +545,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 +649,78 @@ 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
|
||||||
@@ -53,3 +53,29 @@ 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
|
||||||
|
|||||||
@@ -57,4 +57,101 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditLog",
|
||||||
|
"AuditOutboxDelivery",
|
||||||
|
"AuditOutboxEvent",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
@@ -8,7 +10,10 @@ from govoplan_core.core.access import (
|
|||||||
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 drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, 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
|
||||||
|
|
||||||
|
|
||||||
@@ -33,30 +38,109 @@ 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="audit",
|
id="audit",
|
||||||
name="Audit",
|
name="Audit",
|
||||||
version="0.1.7",
|
version="0.1.15",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
|
documentation=(
|
||||||
|
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"),
|
||||||
|
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"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"audit.recording",
|
||||||
|
"audit.retention",
|
||||||
|
"audit.event-outbox",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
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.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.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,
|
||||||
},
|
},
|
||||||
|
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 archival and evidentiary export profiles are not yet reference-ready.",),
|
||||||
|
owned_concepts=("audit record", "audit retention", "transactional event outbox"),
|
||||||
|
non_owned_concepts=("domain record", "policy decision", "external effect"),
|
||||||
|
recovery_docs=("README.md",),
|
||||||
|
security_docs=("docs/AUDIT_TRACE_CONTEXT.md",),
|
||||||
|
operations_docs=("README.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 @@
|
|||||||
|
"""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")
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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.assertIn("govoplan-core>=0.1.8", 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,19 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+8
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/audit-webui",
|
"name": "@govoplan/audit-webui",
|
||||||
"version": "0.1.7",
|
"version": "0.1.15",
|
||||||
"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.7",
|
"@govoplan/core-webui": "^0.1.15",
|
||||||
"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.");
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
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,
|
||||||
|
i18nMessage,
|
||||||
mergeDeltaRows,
|
mergeDeltaRows,
|
||||||
|
TableActionGroup,
|
||||||
useDeltaWatermarks,
|
useDeltaWatermarks,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
@@ -22,6 +24,35 @@ type Props = {
|
|||||||
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",
|
||||||
|
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" },
|
||||||
@@ -113,28 +144,49 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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={(
|
||||||
|
<>
|
||||||
|
<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 +194,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 +211,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
|
||||||
|
open={Boolean(selected)}
|
||||||
|
title={I18N.eventDetails}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
className="admin-dialog admin-dialog-wide"
|
||||||
|
footer={<Button onClick={() => setSelected(null)}>{I18N.close}</Button>}>
|
||||||
{selected && (
|
{selected && (
|
||||||
<>
|
<>
|
||||||
<dl className="admin-details-grid">
|
<dl className="admin-details-grid">
|
||||||
<div><dt>Scope</dt><dd>{selected.scope}</dd></div>
|
<div><dt>{I18N.scopeLabel}</dt><dd>{selected.scope}</dd></div>
|
||||||
<div><dt>Action</dt><dd>{selected.action}</dd></div>
|
<div><dt>{I18N.actionLabel}</dt><dd>{selected.action}</dd></div>
|
||||||
<div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div>
|
<div><dt>{I18N.actor}</dt><dd>{selected.actor_email || I18N.system}</dd></div>
|
||||||
<div><dt>Object</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
|
<div><dt>{I18N.object}</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
|
||||||
<div><dt>Tenant context</dt><dd>{selected.tenant_id || "-"}</dd></div>
|
<div><dt>{I18N.tenantContext}</dt><dd>{selected.tenant_id || "-"}</dd></div>
|
||||||
<div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
|
<div><dt>{I18N.time}</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<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 +242,23 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,50 @@
|
|||||||
|
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"
|
||||||
|
},
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
};
|
||||||
+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