From 702170805b3952eae0699ae47952e7570c58eeba Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 14:42:47 +0100 Subject: [PATCH] feat: audit page writes that no admin route announced (duneorg/dune#127) Inline edits and other writers fire the page event with the person as actor; the admin now logs page.create/update/delete for them. Events its own routes announce are marked and skipped. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 +++ mod.ts | 5 +++ src/admin/page-audit.ts | 55 +++++++++++++++++++++++++++++++ src/admin/page-events.ts | 7 +++- tests/admin/page_audit_test.ts | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/admin/page-audit.ts create mode 100644 tests/admin/page_audit_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d72fd2..bb422ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ follows [Semantic Versioning](https://semver.org). is governed by core's `system.role_grants` like anyone else; the direct tuple an earlier version seeded for such an account is revoked at start (logged as `admin.authz.stale_direct_grants_revoked`). On the default grants nothing changes for them: their group membership still grants the relation. +- **Page writes that no admin route announced reach the audit trail (duneorg/dune#127).** The inline-edit bar, + a plugin or any other writer fires the page event with the person's name as `actor`; the admin now writes a + `page.create` / `page.update` / `page.delete` entry for it (no IP or user agent: the event does not carry + them). Events the admin's own routes announce are marked and skipped, so nothing is logged twice. - **Content webhooks are a listener on the page events**, not a call in each admin route: a write from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the signed-in user as `actor`, which the webhook payload carries. diff --git a/mod.ts b/mod.ts index b3a71ff..d6d0777 100644 --- a/mod.ts +++ b/mod.ts @@ -38,6 +38,7 @@ import denoJson from "./deno.json" with { type: "json" }; import { adminSettings } from "./src/admin/settings.ts"; import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts"; import { reconcileAdminTuples } from "./src/admin/admin-tuples.ts"; +import { listenForPageAudit } from "./src/admin/page-audit.ts"; import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; @@ -298,6 +299,10 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { security: services.security, }); + // Writes that did not come through an admin route (the inline-edit bar, a + // plugin) still reach the audit trail, named by the event's `actor`. + listenForPageAudit({ hooks: runtime.hooks, audit: () => setupState?.auditLogger }); + // ── Default content editor ──────────────────────────────────────────────── const inlineEdit = capabilities.get("editor.inline"); const contentEditor = capabilities.get(ADMIN_EDITOR) ?? createBlockEditorPlugin(); diff --git a/src/admin/page-audit.ts b/src/admin/page-audit.ts new file mode 100644 index 0000000..74959ef --- /dev/null +++ b/src/admin/page-audit.ts @@ -0,0 +1,55 @@ +/** + * Audit page writes that did not come through an admin route. + * + * The admin's own routes (create, update, delete) write their audit entry + * themselves, with the request's IP and user agent. Whoever else writes a page + * (the inline-edit bar, a plugin, MCP) fires the same page event with the + * person's name as `actor`, so this listener gives those writes an entry too. + * Events the admin's routes announced carry the {@link FROM_ADMIN_ROUTE} mark + * and are skipped, so nothing is logged twice. Entries from here have no IP or + * user agent: the event does not carry them. + * + * @module + */ + +import type { HookRegistry } from "@dune/core/hooks"; +import type { AuditEventType, AuditLogger } from "../audit/mod.ts"; + +/** + * Set (non-enumerably, so it never reaches a webhook body) on the payload of + * an event an admin route announced, which that route has already audited. + */ +export const FROM_ADMIN_ROUTE: unique symbol = Symbol("dune.admin.fromRoute"); + +const EVENTS: Array<[event: "onPageCreate" | "onPageUpdate" | "onPageDelete", audit: AuditEventType]> = [ + ["onPageCreate", "page.create"], + ["onPageUpdate", "page.update"], + ["onPageDelete", "page.delete"], +]; + +/** Audit page create, update and delete events that no admin route announced. */ +export function listenForPageAudit(opts: { + hooks: Pick; + audit: () => Pick | null | undefined; +}): void { + for (const [event, auditEvent] of EVENTS) { + opts.hooks.on(event, ({ data }) => { + const payload = data as { sourcePath?: string; actor?: string; [FROM_ADMIN_ROUTE]?: true }; + if (payload[FROM_ADMIN_ROUTE]) return; + const logger = opts.audit(); + if (!logger || typeof payload.sourcePath !== "string") return; + const actor = typeof payload.actor === "string" && payload.actor !== "" + ? { userId: payload.actor, username: payload.actor, name: payload.actor } + : null; + void logger.log({ + event: auditEvent, + actor, + ip: null, + userAgent: null, + target: { type: "page", id: payload.sourcePath }, + detail: { via: "page event" }, + outcome: "success", + }).catch(() => {}); + }); + } +} diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index a3e79ca..56ea470 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -14,6 +14,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "./types.ts"; +import { FROM_ADMIN_ROUTE } from "./page-audit.ts"; /** Paths are relative to the content directory. */ export type PageEvent = @@ -27,7 +28,11 @@ export function announcePageEvent(ctx: FreshContext, e: PageEvent): const { hooks } = ctx.state.adminContext; const user = ctx.state.auth?.user; const actor = user ? (user.username ?? user.name ?? user.id) : undefined; - hooks?.fire(e.event, actor ? { ...e.payload, actor } : e.payload).catch((err: unknown) => + // The route that announces this writes its own audit entry (with IP and user agent); + // the mark stops the page-audit listener from adding a second one. + const payload = actor ? { ...e.payload, actor } : { ...e.payload }; + Object.defineProperty(payload, FROM_ADMIN_ROUTE, { value: true, enumerable: false }); + hooks?.fire(e.event, payload).catch((err: unknown) => console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) ); } diff --git a/tests/admin/page_audit_test.ts b/tests/admin/page_audit_test.ts new file mode 100644 index 0000000..fd1ac06 --- /dev/null +++ b/tests/admin/page_audit_test.ts @@ -0,0 +1,59 @@ +/** Page writes that no admin route announced reach the audit trail, once. */ + +import { assertEquals } from "@std/assert"; +import { FROM_ADMIN_ROUTE, listenForPageAudit } from "../../src/admin/page-audit.ts"; + +type Handler = (ctx: { data: unknown }) => void; + +function setup() { + const handlers = new Map(); + const logged: Array> = []; + listenForPageAudit({ + // deno-lint-ignore no-explicit-any + hooks: { on: (event: string, h: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), h]) } as any, + audit: () => ({ log: (entry: Record) => (logged.push(entry), Promise.resolve()) }) as never, + }); + return { logged, fire: (event: string, data: unknown) => (handlers.get(event) ?? []).forEach((h) => h({ data })) }; +} + +Deno.test("an update by the inline-edit bar is audited under the editor's name", () => { + const { logged, fire } = setup(); + fire("onPageUpdate", { sourcePath: "about/default.md", actor: "alice" }); + assertEquals(logged.length, 1); + assertEquals(logged[0].event, "page.update"); + assertEquals(logged[0].actor, { userId: "alice", username: "alice", name: "alice" }); + assertEquals(logged[0].target, { type: "page", id: "about/default.md" }); + assertEquals(logged[0].ip, null); +}); + +Deno.test("create and delete are audited too; a write with no actor is audited as the system", () => { + const { logged, fire } = setup(); + fire("onPageCreate", { sourcePath: "new/default.md", title: "New" }); + fire("onPageDelete", { sourcePath: "old/default.md", actor: "bob" }); + assertEquals(logged.map((l) => l.event), ["page.create", "page.delete"]); + assertEquals(logged[0].actor, null); +}); + +Deno.test("an event an admin route announced is not audited again", () => { + const { logged, fire } = setup(); + const payload = { sourcePath: "about/default.md", actor: "ada" }; + Object.defineProperty(payload, FROM_ADMIN_ROUTE, { value: true, enumerable: false }); + fire("onPageUpdate", payload); + assertEquals(logged, []); + // The mark never shows up when the payload is serialised (webhook bodies). + assertEquals(JSON.stringify(payload), '{"sourcePath":"about/default.md","actor":"ada"}'); +}); + +Deno.test("no audit trail configured, or a payload without a path, logs nothing and does not throw", () => { + const off = [] as unknown[]; + const handlers: Array<(c: { data: unknown }) => void> = []; + listenForPageAudit({ + // deno-lint-ignore no-explicit-any + hooks: { on: (_e: string, h: (c: { data: unknown }) => void) => handlers.push(h) } as any, + audit: () => null, + }); + for (const h of handlers) h({ data: { sourcePath: "a.md" } }); + const { logged, fire } = setup(); + fire("onPageUpdate", { actor: "x" }); + assertEquals([off, logged], [[], []]); +});