20 Commits
Author SHA1 Message Date
zemion 1d993b26c8 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:57 +02:00
zemion 4613b54c06 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:51:58 +02:00
zemion 6f095eb562 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 10s
2026-08-04 15:18:08 +02:00
zemion 0cbb249d74 Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion f0d2916d46 Harden module package publication 2026-08-04 14:02:39 +02:00
zemion c1e6c15866 Partition durable events by tenant entitlement 2026-08-04 09:29:35 +02:00
zemion 451361cc05 Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 6d3fcc1572 Migrate Audit evidence interfaces 2026-08-03 10:43:24 +02:00
zemion 4177287b22 feat: strengthen transactional audit delivery 2026-08-01 17:48:23 +02:00
zemion 57ceef0173 Align WebUI runtime peer dependencies 2026-07-31 02:48:56 +02:00
zemion 078e9144b1 feat: classify audit administration surfaces 2026-07-30 17:42:06 +02:00
zemion f74e8cf85b Persist durable event consumer delivery state 2026-07-29 17:34:52 +02:00
zemion 0167ab752a feat: publish durable audit outbox events 2026-07-29 14:16:28 +02:00
zemion 1479946729 Declare administration View surfaces 2026-07-28 21:04:54 +02:00
zemion 130f738970 fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion a34935da02 refactor(api): share full audit delta response 2026-07-21 13:29:19 +02:00
zemion 86b20c65cb refactor(webui): use central table actions 2026-07-21 12:01:24 +02:00
zemion a96dc228b8 Clean audit security scan and test resources 2026-07-21 03:16:23 +02:00
zemion 5e4f84a789 intermittent commit 2026-07-14 13:22:10 +02:00
zemion d3d2c60d7d Release v0.1.8 2026-07-11 16:49:00 +02:00
24 changed files with 2123 additions and 234 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -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.
+8
View File
@@ -1,5 +1,9 @@
# GovOPlaN Audit
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-audit` owns audit API route contributions and audit administration
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
operational context fields used by admin, installer, and module lifecycle audit
entries.
The administration surface archetypes, consequence classification, and
verification contract are recorded in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
+15 -3
View File
@@ -93,12 +93,24 @@ Commands and events are separate concepts:
be written to the audit outbox before delivery.
`govoplan_audit.backend.outbox.SqlAuditOutbox` persists platform events in
`audit_outbox_events`. Dispatchers can later call
`dispatch_pending_platform_events()` to publish pending events and record retry
state. The outbox payload stores the full governed event envelope:
`audit_outbox_events` and one durable state row per allowlisted consumer in
`audit_outbox_deliveries`. Dispatchers supply stable consumer IDs and
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,
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
they describe is known. Long-running operators and installers should model
requested work as commands first, then emit facts as events as each step
+40
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/audit-webui",
"version": "0.1.7",
"version": "0.1.17",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,11 +18,11 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-audit"
version = "0.1.7"
version = "0.1.17"
description = "GovOPlaN audit platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.7",
"govoplan-core>=0.1.17",
]
[tool.setuptools.packages.find]
+266 -150
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
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_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.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
@@ -16,13 +23,38 @@ from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
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"])
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:
registry = get_registry()
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)
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]:
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 [
@@ -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))
@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")),
):
def _prepare_audit_admin_query(
session: Session,
principal: ApiPrincipal,
*,
tenant_id: str | None,
all_tenants: bool,
audit_scope: str | None,
limit: int,
page_size: int | None,
sort_by: str,
sort_direction: str,
filter_time: str | None,
filter_actor: str | None,
filter_action: str | None,
filter_object: str | None,
filter_tenant: str | None,
) -> AuditAdminQueryContext:
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
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.")
@@ -333,14 +392,13 @@ def list_admin_audit(
object_text = func.coalesce(AuditLog.object_type, "") + " " + func.coalesce(AuditLog.object_id, "")
access_admin = _access_administration()
filters = [
for condition in (
_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)
@@ -353,7 +411,6 @@ def list_admin_audit(
}
sort_column = sort_columns[sort_by]
order = sort_column.asc() if sort_direction == "asc" else sort_column.desc()
ordered_query = query.order_by(order, AuditLog.id.desc())
total = query.count()
effective_page_size = page_size or limit
pages = max(1, (total + effective_page_size - 1) // effective_page_size)
@@ -372,47 +429,100 @@ def list_admin_audit(
sort_direction=sort_direction,
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
if cursor:
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:
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:
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
start_cursor = cursor
else:
if page is not None or page_size is not None:
effective_page = min(page or 1, pages)
effective_offset = (effective_page - 1) * effective_page_size
effective_page = min(page or 1, context.pages)
effective_offset = (effective_page - 1) * context.effective_page_size
else:
effective_page = offset // effective_page_size + 1
effective_page = offset // context.effective_page_size + 1
effective_offset = offset
page_query = query
page_query = context.query
if effective_offset > 0:
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
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 = rows_plus_one[:effective_page_size]
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[:context.effective_page_size]
next_cursor = (
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
if len(rows_plus_one) > effective_page_size and rows else None
_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 AuditAdminListResponse(
total=total,
total=context.total,
page=effective_page,
page_size=effective_page_size,
pages=pages,
page_size=context.effective_page_size,
pages=context.pages,
cursor=start_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),
principal: ApiPrincipal = Depends(require_any_scope("audit:read", "system:audit:read")),
):
effective_scope = audit_scope or ("all" if all_tenants else "tenant")
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.")
if sort_by not in {"time", "actor", "action", "object", "tenant"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unsupported audit sort column.")
if sort_direction not in {"asc", "desc"}:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Audit sort direction must be asc or desc.")
query = session.query(AuditLog)
resolved_tenant_id: str | None = None
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(
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,
)
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
page_query = query
page_query = context.query
if cursor:
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:
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
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if since is None:
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all()
rows = rows_plus_one[:effective_page_size]
next_cursor = (
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
if len(rows_plus_one) > effective_page_size and rows else None
)
return AuditAdminDeltaResponse(
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,
return _full_audit_delta_response(
session,
context=context,
page_query=page_query,
start_cursor=start_cursor,
sort_by=sort_by,
sort_direction=sort_direction,
)
entries, has_more = _audit_delta_entries(
session,
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
effective_scope=context.effective_scope,
tenant_id=context.resolved_tenant_id,
since=since,
limit=effective_page_size,
limit=context.effective_page_size,
)
if entries is None:
rows_plus_one = page_query.order_by(order, AuditLog.id.desc()).limit(effective_page_size + 1).all()
rows = rows_plus_one[:effective_page_size]
next_cursor = (
_audit_cursor_for_row(rows[-1], sort_by=sort_by, sort_direction=sort_direction, fingerprint=fingerprint)
if len(rows_plus_one) > effective_page_size and rows else None
)
return AuditAdminDeltaResponse(
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,
return _full_audit_delta_response(
session,
context=context,
page_query=page_query,
start_cursor=start_cursor,
sort_by=sort_by,
sort_direction=sort_direction,
)
changed_ids = [entry.resource_id for entry in entries if entry.resource_type == "audit_log"]
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 []
)
return AuditAdminDeltaResponse(
total=total,
total=context.total,
page=1,
page_size=effective_page_size,
pages=pages,
page_size=context.effective_page_size,
pages=context.pages,
cursor=start_cursor,
next_cursor=None,
items=_audit_items(session, rows, access_admin),
items=_audit_items(session, rows, context.access_admin),
deleted=[],
watermark=_audit_delta_response_watermark(
session,
effective_scope=effective_scope,
tenant_id=resolved_tenant_id,
effective_scope=context.effective_scope,
tenant_id=context.resolved_tenant_id,
entries=entries,
has_more=has_more,
),
@@ -608,3 +649,78 @@ def list_audit_log(
query = query.filter(AuditLog.object_id == object_id)
items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
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)
+27 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
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
@@ -53,3 +53,29 @@ class AuditLogItemResponse(BaseModel):
class AuditLogListResponse(BaseModel):
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
+98 -1
View File
@@ -57,4 +57,101 @@ class AuditOutboxEvent(Base, TimestampMixin):
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",
]
+88 -4
View File
@@ -1,5 +1,7 @@
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_core.core.access import (
CAPABILITY_AUDIT_RECORDER,
@@ -8,7 +10,10 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import 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
@@ -33,30 +38,109 @@ def _audit_retention(context: ModuleContext):
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(
id="audit",
name="Audit",
version="0.1.7",
version="0.1.17",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
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(
module_id="audit",
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(
module_id="audit",
metadata=Base.metadata,
script_location=str(
Path(__file__).with_name("migrations") / "versions"
),
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.",
),
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_AUDIT_RECORDER: _audit_recorder,
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."""
@@ -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."""
@@ -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")
+526 -24
View File
@@ -1,14 +1,18 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timedelta, timezone
from typing import Any, cast
from sqlalchemy import or_
from sqlalchemy import delete, func, or_, select
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 (
DurableEventConsumer,
EventActorRef,
EventClassification,
EventObjectRef,
@@ -17,14 +21,29 @@ from govoplan_core.core.events import (
ensure_event_trace,
publish_platform_event,
)
from govoplan_core.core.institutional import GovernedContextEnvelope
EventDispatcher = Callable[[PlatformEvent], None]
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:
db = _session(session)
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(
event_id=traced.event_id,
event_type=traced.type,
@@ -36,48 +55,507 @@ class SqlAuditOutbox:
status="pending",
)
db.add(item)
db.flush()
return item
def dispatch_pending(
self,
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,
) -> 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)
now = datetime.now(timezone.utc)
rows = (
consumers_by_id = _consumer_map(consumers)
query = (
db.query(AuditOutboxEvent)
.filter(
AuditOutboxEvent.status.in_(("pending", "failed")),
AuditOutboxEvent.status.in_(
("pending", "failed", "retrying")
),
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)))
.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:
try:
dispatcher(_event_from_payload(row.payload))
except Exception as exc: # noqa: BLE001 - dispatcher errors must be retained for retry/diagnostics.
row.status = "failed"
row.attempts += 1
row.last_error = str(exc)
row.next_attempt_at = now + _retry_delay(row.attempts)
counts["failed"] += 1
continue
row.status = "dispatched"
row.attempts += 1
row.dispatched_at = now
row.next_attempt_at = None
row.last_error = None
counts["dispatched"] += 1
event = _event_from_payload(row.payload)
deliveries = _event_deliveries(
db,
row=row,
event=event,
consumers=consumers_by_id.values(),
)
_dispatch_event_deliveries(
event,
deliveries=deliveries,
consumers_by_id=consumers_by_id,
now=now,
max_attempts=self._max_attempts,
counts=counts,
)
_finish_event_dispatch(
row,
deliveries=deliveries,
observer=effective_observer,
event=event,
now=now,
counts=counts,
)
db.flush()
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:
return SqlAuditOutbox().enqueue(session, event)
@@ -97,6 +575,17 @@ def _retry_delay(attempts: int) -> timedelta:
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:
return PlatformEvent(
type=str(payload["type"]),
@@ -111,6 +600,19 @@ def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
subject=_object_ref(payload.get("subject")),
resource=_object_ref(payload.get("resource")),
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
View File
@@ -1,14 +1,28 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
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_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
@@ -30,12 +44,23 @@ class AuditCommandBusTests(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:")
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine)
self.addCleanup(engine.dispose)
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()
seen: list[PlatformEvent] = []
observed: list[PlatformEvent] = []
with Session() as session:
event = PlatformEvent(
@@ -52,31 +77,343 @@ class AuditOutboxTests(unittest.TestCase):
self.assertEqual(event.event_id, row.correlation_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(observed))
self.assertEqual("tenant.created", seen[0].type)
self.assertEqual("dispatched", row.status)
self.assertEqual(1, row.attempts)
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:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine)
def test_dispatch_partitions_pending_events_by_tenant(self) -> None:
Session = self._database()
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:
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("failed", row.status)
self.assertEqual(1, counts["retrying"])
self.assertEqual("retrying", row.status)
self.assertEqual(1, row.attempts)
self.assertEqual("offline", row.last_error)
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__":
+16 -1
View File
@@ -4,6 +4,8 @@ import pathlib
import tomllib
import unittest
from govoplan_audit.backend.manifest import get_manifest
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"]
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))
def test_audit_source_does_not_import_access_implementation(self) -> None:
@@ -25,6 +27,19 @@ class AuditModuleContractTests(unittest.TestCase):
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__":
unittest.main()
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/audit-webui",
"version": "0.1.7",
"version": "0.1.17",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -12,12 +12,15 @@
"import": "./src/index.ts"
}
},
"scripts": {
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@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.");
+102 -21
View File
@@ -1,14 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import {
AdminIconButton,
AdminPageLayout,
adminErrorMessage,
Button,
DataGrid,
Dialog,
DocumentationHelpLink,
formatAdminDateTime as formatDateTime,
i18nMessage,
mergeDeltaRows,
TableActionGroup,
useDeltaWatermarks,
type ApiSettings,
type AuthInfo,
@@ -22,6 +24,35 @@ type Props = {
auth: AuthInfo;
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 = {
sort: { columnId: "time", direction: "desc" },
@@ -113,28 +144,49 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
}, []);
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: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
{ id: "action", header: "Action", 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() },
...(systemMode ? [{ id: "tenant", header: "Tenant context", 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: "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: 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: I18N.actionLabel, width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ 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: I18N.tenantContext, width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "-" }] : []),
{ 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]);
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 lastShown = Math.min(total, page * pageSize);
const pageDescription = systemMode
? `System-level administrative history, showing ${firstShown}-${lastShown} of ${total}.`
: `Tenant-level administrative history for the active tenant, showing ${firstShown}-${lastShown} of ${total}.`;
const pageDescription = i18nMessage(
systemMode ? I18N.systemDescription : I18N.tenantDescription,
{ value0: firstShown, value1: lastShown, value2: total }
);
return (
<>
<AdminPageLayout
title={systemMode ? "System audit" : "Tenant audit"}
title={systemMode ? I18N.systemAudit : I18N.tenantAudit}
description={pageDescription}
loading={loading}
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">
<DataGrid
id={systemMode ? "admin-system-audit-v6" : "admin-tenant-audit-v6"}
@@ -142,7 +194,7 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
columns={columns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="No administrative audit records found."
emptyText={I18N.noRecords}
className="admin-audit-grid"
initialSort={{ columnId: "time", direction: "desc" }}
pagination={{
@@ -159,18 +211,30 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
/>
</div>
</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 && (
<>
<dl className="admin-details-grid">
<div><dt>Scope</dt><dd>{selected.scope}</dd></div>
<div><dt>Action</dt><dd>{selected.action}</dd></div>
<div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div>
<div><dt>Object</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
<div><dt>Tenant context</dt><dd>{selected.tenant_id || "-"}</dd></div>
<div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
<div><dt>{I18N.scopeLabel}</dt><dd>{selected.scope}</dd></div>
<div><dt>{I18N.actionLabel}</dt><dd>{selected.action}</dd></div>
<div><dt>{I18N.actor}</dt><dd>{selected.actor_email || I18N.system}</dd></div>
<div><dt>{I18N.object}</dt><dd>{selected.object_type || "-"} {selected.object_id || ""}</dd></div>
<div><dt>{I18N.tenantContext}</dt><dd>{selected.tenant_id || "-"}</dd></div>
<div><dt>{I18N.time}</dt><dd>{formatDateTime(selected.created_at)}</dd></div>
</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>
@@ -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 {
return (left, right) => {
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
+50
View File
@@ -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
View File
@@ -1,13 +1,21 @@
import { createElement, lazy } from "react";
import { type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
const AdminAuditPanel = lazy(() => import("./features/audit/AdminAuditPanel"));
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const auditAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-audit",
label: "Audit",
moduleId: "audit",
kind: "management",
surfaceId: "audit.admin.system",
label: "i18n:govoplan-audit.system_audit.f1a20815",
group: "SYSTEM",
order: 90,
allOf: ["system:audit:read"],
@@ -19,7 +27,10 @@ const auditAdminSections: AdminSectionsUiCapability = {
},
{
id: "tenant-audit",
label: "Audit",
moduleId: "audit",
kind: "management",
surfaceId: "audit.admin.tenant",
label: "i18n:govoplan-audit.tenant_audit.f1a20817",
group: "TENANT",
order: 100,
allOf: ["audit:read"],
@@ -35,8 +46,13 @@ const auditAdminSections: AdminSectionsUiCapability = {
export const auditModule: PlatformWebModule = {
id: "audit",
label: "Audit",
version: "0.1.6",
version: "0.1.8",
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: {
"admin.sections": auditAdminSections
}