From 7ffb16d98198f7485275bb3204ed71fe13f9e76a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 02:34:56 +0200 Subject: [PATCH] Release v0.1.7 --- README.md | 12 +- package.json | 32 +++ pyproject.toml | 4 +- src/govoplan_audit/backend/manifest.py | 8 +- webui/package.json | 27 +++ webui/src/api/audit.ts | 74 +++++++ webui/src/features/audit/AdminAuditPanel.tsx | 200 +++++++++++++++++++ webui/src/index.ts | 5 + webui/src/module.ts | 45 +++++ 9 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 package.json create mode 100644 webui/package.json create mode 100644 webui/src/api/audit.ts create mode 100644 webui/src/features/audit/AdminAuditPanel.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts diff --git a/README.md b/README.md index d776460..5aba8b1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # GovOPlaN Audit -`govoplan-audit` owns audit API route contributions during the GovOPlaN module -split. +`govoplan-audit` owns audit API route contributions and audit administration +WebUI sections during the GovOPlaN module split. This repository owns the live `audit_log` table, audit API route -contributions, and the target boundary for future audit sink/export capability -work. +contributions, the `@govoplan/audit-webui` package, and the target boundary +for future audit sink/export capability work. + +The WebUI package contributes the `system-audit` and `tenant-audit` admin +sections through the shared `admin.sections` UI capability. The admin shell +does not render audit panels unless this module is installed and enabled. It also owns the audit command/event separation and production delivery foundation: diff --git a/package.json b/package.json new file mode 100644 index 0000000..befe986 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/audit-webui", + "version": "0.1.7", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + } + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml index b4cec39..59f8590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-audit" -version = "0.1.6" +version = "0.1.7" description = "GovOPlaN audit platform module." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.6", + "govoplan-core>=0.1.7", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_audit/backend/manifest.py b/src/govoplan_audit/backend/manifest.py index dcfc4c0..32a9895 100644 --- a/src/govoplan_audit/backend/manifest.py +++ b/src/govoplan_audit/backend/manifest.py @@ -8,7 +8,7 @@ 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 MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest from govoplan_core.db.base import Base @@ -36,9 +36,13 @@ def _audit_retention(context: ModuleContext): manifest = ModuleManifest( id="audit", name="Audit", - version="0.1.6", + version="0.1.7", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), route_factory=_route_factory, + frontend=FrontendModule( + module_id="audit", + package_name="@govoplan/audit-webui", + ), migration_spec=MigrationSpec( module_id="audit", metadata=Base.metadata, diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..82b096a --- /dev/null +++ b/webui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@govoplan/audit-webui", + "version": "0.1.7", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.7", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/audit.ts b/webui/src/api/audit.ts new file mode 100644 index 0000000..2e358d4 --- /dev/null +++ b/webui/src/api/audit.ts @@ -0,0 +1,74 @@ +import { apiFetch, type ApiSettings, type DeltaDeletedItem } from "@govoplan/core-webui"; + +export type AuditAdminItem = { + id: string; + scope: "tenant" | "system"; + tenant_id?: string | null; + actor_email?: string | null; + action: string; + object_type?: string | null; + object_id?: string | null; + details: Record; + created_at: string; +}; + +export type AuditSortBy = "time" | "actor" | "action" | "object" | "tenant"; + +export type AuditQueryOptions = { + tenantId?: string | null; + allTenants?: boolean; + scope?: "tenant" | "system"; + limit?: number; + offset?: number; + page?: number; + pageSize?: number; + cursor?: string | null; + sortBy?: AuditSortBy; + sortDirection?: "asc" | "desc"; + filters?: Partial>; +}; + +export type AuditAdminListResponse = { + items: AuditAdminItem[]; + total: number; + page: number; + page_size: number; + pages: number; + cursor?: string | null; + next_cursor?: string | null; +}; + +export type AuditAdminDeltaResponse = AuditAdminListResponse & { + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + +function auditQuery(options: AuditQueryOptions & { since?: string | null } = {}): string { + const params = new URLSearchParams(); + if (options.tenantId) params.set("tenant_id", options.tenantId); + if (options.allTenants) params.set("all_tenants", "true"); + if (options.scope) params.set("scope", options.scope); + if (options.limit) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + if (options.page) params.set("page", String(options.page)); + if (options.pageSize) params.set("page_size", String(options.pageSize)); + if (options.cursor) params.set("cursor", options.cursor); + if (options.sortBy) params.set("sort_by", options.sortBy); + if (options.sortDirection) params.set("sort_direction", options.sortDirection); + if (options.since) params.set("since", options.since); + for (const [column, value] of Object.entries(options.filters ?? {})) { + if (value?.trim()) params.set(`filter_${column}`, value); + } + const suffix = params.toString(); + return suffix ? `?${suffix}` : ""; +} + +export function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise { + return apiFetch(settings, `/api/v1/admin/audit${auditQuery(options)}`); +} + +export function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise { + return apiFetch(settings, `/api/v1/admin/audit/delta${auditQuery(options)}`); +} diff --git a/webui/src/features/audit/AdminAuditPanel.tsx b/webui/src/features/audit/AdminAuditPanel.tsx new file mode 100644 index 0000000..1c6817a --- /dev/null +++ b/webui/src/features/audit/AdminAuditPanel.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Search } from "lucide-react"; +import { + AdminIconButton, + AdminPageLayout, + adminErrorMessage, + Button, + DataGrid, + Dialog, + formatAdminDateTime as formatDateTime, + mergeDeltaRows, + useDeltaWatermarks, + type ApiSettings, + type AuthInfo, + type DataGridColumn, + type DataGridQueryState +} from "@govoplan/core-webui"; +import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem, type AuditSortBy } from "../../api/audit"; + +type Props = { + settings: ApiSettings; + auth: AuthInfo; + systemMode?: boolean; +}; + +const DEFAULT_QUERY: DataGridQueryState = { + sort: { columnId: "time", direction: "desc" }, + filters: {} +}; + +export default function AdminAuditPanel({ settings, auth, systemMode = false }: Props) { + const [items, setItems] = useState([]); + const itemsRef = useRef([]); + const pageItemsRef = useRef>({}); + const pageCursorsRef = useRef>({ 1: null }); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [query, setQuery] = useState(DEFAULT_QUERY); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [reloadToken, setReloadToken] = useState(0); + const tenantId = (auth.active_tenant ?? auth.tenant).id; + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const sortColumn = query.sort?.columnId; + const sortBy = sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn) + ? sortColumn as AuditSortBy + : "time"; + const sortDirection = query.sort?.direction ?? "desc"; + const filters = query.filters; + const pageCursor = page === 1 ? null : pageCursorsRef.current[page]; + const deltaMode = page === 1 || pageCursor !== undefined; + const requestOptions = { + scope: systemMode ? "system" as const : "tenant" as const, + page, + pageSize, + cursor: pageCursor, + sortBy, + sortDirection, + filters + }; + const deltaKey = `audit:${systemMode ? "system" : "tenant"}:${tenantId}:${pageSize}:${page}:${pageCursor ?? "root"}:${JSON.stringify({ sortBy, sortDirection, filters })}`; + const response = deltaMode + ? await fetchAdminAuditDelta(settings, { ...requestOptions, since: getDeltaWatermark(deltaKey) }) + : await fetchAdminAudit(settings, requestOptions); + const baseItems = pageItemsRef.current[deltaKey] ?? []; + const nextItems = "full" in response && !response.full + ? mergeDeltaRows(baseItems, response.items, response.deleted, (item) => item.id, { sort: compareAuditEvents(sortBy, sortDirection) }).slice(0, pageSize) + : response.items; + pageItemsRef.current[deltaKey] = nextItems; + itemsRef.current = nextItems; + setItems(nextItems); + setTotal(response.total); + if (!deltaMode && response.page !== page) setPage(response.page); + if (response.cursor !== undefined) pageCursorsRef.current[page] = response.cursor ?? null; + if (response.next_cursor !== undefined) { + if (response.next_cursor) pageCursorsRef.current[page + 1] = response.next_cursor; + else delete pageCursorsRef.current[page + 1]; + } + if ("full" in response && !response.full && page === 1 && (response.items.length > 0 || response.deleted.length > 0)) { + pageCursorsRef.current = { 1: null }; + } + if ("watermark" in response) setDeltaWatermark(deltaKey, response.watermark); + if (!deltaMode) resetDeltaWatermark(deltaKey); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]); + + useEffect(() => { + itemsRef.current = []; + pageItemsRef.current = {}; + pageCursorsRef.current = { 1: null }; + resetDeltaWatermark(); + }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, systemMode, tenantId, pageSize, query, resetDeltaWatermark]); + + useEffect(() => { void load(); }, [load]); + + const handleQueryChange = useCallback((next: DataGridQueryState) => { + setQuery((current) => { + if (JSON.stringify(current) === JSON.stringify(next)) return current; + setPage(1); + return next; + }); + }, []); + + const columns = useMemo[]>(() => [ + { 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) =>
} onClick={() => setSelected(row)} />
} + ], [systemMode]); + + 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}.`; + + return ( + <> + setReloadToken((value) => value + 1)} disabled={loading}>Reload}> +
+ row.id} + emptyText="No administrative audit records found." + className="admin-audit-grid" + initialSort={{ columnId: "time", direction: "desc" }} + pagination={{ + mode: "server", + page, + pageSize, + totalRows: total, + pageSizeOptions: [10, 25, 50, 100, 250], + disabled: loading, + onPageChange: setPage, + onPageSizeChange: (next) => { setPageSize(next); setPage(1); } + }} + onQueryChange={handleQueryChange} + /> +
+
+ setSelected(null)} className="admin-dialog admin-dialog-wide" footer={}> + {selected && ( + <> +
+
Scope
{selected.scope}
+
Action
{selected.action}
+
Actor
{selected.actor_email || "System"}
+
Object
{selected.object_type || "-"} {selected.object_id || ""}
+
Tenant context
{selected.tenant_id || "-"}
+
Time
{formatDateTime(selected.created_at)}
+
+
{JSON.stringify(selected.details, null, 2)}
+ + )} +
+ + ); +} + +function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number { + return (left, right) => { + const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy)); + const directed = sortDirection === "asc" ? primary : -primary; + return directed || right.id.localeCompare(left.id); + }; +} + +function auditSortValue(item: AuditAdminItem, sortBy: AuditSortBy): string | number { + if (sortBy === "time") return new Date(item.created_at).getTime(); + if (sortBy === "actor") return item.actor_email || "System"; + if (sortBy === "action") return item.action; + if (sortBy === "object") return `${item.object_type || ""} ${item.object_id || ""}`; + return item.tenant_id || ""; +} + +function compareAuditValues(left: string | number, right: string | number): number { + if (typeof left === "number" && typeof right === "number") return left - right; + return String(left).localeCompare(String(right)); +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..6241a0e --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,5 @@ +export { default } from "./module"; +export * from "./module"; +export * from "./api/audit"; +export { default as AdminAuditPanel } from "./features/audit/AdminAuditPanel"; +export type { PlatformWebModule } from "@govoplan/core-webui"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..0b2f2e6 --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,45 @@ +import { createElement, lazy } from "react"; +import { type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui"; + +const AdminAuditPanel = lazy(() => import("./features/audit/AdminAuditPanel")); + +const auditAdminSections: AdminSectionsUiCapability = { + sections: [ + { + id: "system-audit", + label: "Audit", + group: "SYSTEM", + order: 90, + allOf: ["system:audit:read"], + render: ({ settings, auth }) => createElement(AdminAuditPanel, { + settings, + auth, + systemMode: true + }) + }, + { + id: "tenant-audit", + label: "Audit", + group: "TENANT", + order: 100, + allOf: ["audit:read"], + render: ({ settings, auth }) => createElement(AdminAuditPanel, { + settings, + auth, + systemMode: false + }) + } + ] +}; + +export const auditModule: PlatformWebModule = { + id: "audit", + label: "Audit", + version: "0.1.6", + dependencies: ["access", "admin"], + uiCapabilities: { + "admin.sections": auditAdminSections + } +}; + +export default auditModule;