diff --git a/README.md b/README.md index f1ecb8f..0f79b2c 100644 --- a/README.md +++ b/README.md @@ -26,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). diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..2c3588a --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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. diff --git a/src/govoplan_audit/backend/manifest.py b/src/govoplan_audit/backend/manifest.py index 0abb4de..a2b95d6 100644 --- a/src/govoplan_audit/backend/manifest.py +++ b/src/govoplan_audit/backend/manifest.py @@ -64,7 +64,15 @@ manifest = ModuleManifest( 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"}, + 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", @@ -74,7 +82,14 @@ manifest = ModuleManifest( documentation_types=("admin",), audience=("auditor", "security_officer", "operator"), related_modules=("policy", "ops"), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": [ + "audit.recording", + "audit.retention", + "audit.event-outbox", + ], + }, ), ), frontend=FrontendModule( diff --git a/tests/test_audit_module_contract.py b/tests/test_audit_module_contract.py index 0e589cb..bce3643 100644 --- a/tests/test_audit_module_contract.py +++ b/tests/test_audit_module_contract.py @@ -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] @@ -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() diff --git a/webui/package.json b/webui/package.json index bf2e528..1893dd6 100644 --- a/webui/package.json +++ b/webui/package.json @@ -12,6 +12,9 @@ "import": "./src/index.ts" } }, + "scripts": { + "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs" + }, "peerDependencies": { "@govoplan/core-webui": "^0.1.9", "lucide-react": "^1.23.0", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..d984081 --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -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, /[]>(() => [ - { 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) }]} /> } + { 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) =>