Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
55 changes: 55 additions & 0 deletions src/admin/page-audit.ts
Original file line number Diff line number Diff line change
@@ -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<HookRegistry, "on">;
audit: () => Pick<AuditLogger, "log"> | 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(() => {});
});
}
}
7 changes: 6 additions & 1 deletion src/admin/page-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -27,7 +28,11 @@ export function announcePageEvent(ctx: FreshContext<AdminState>, 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}`)
);
}
59 changes: 59 additions & 0 deletions tests/admin/page_audit_test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Handler[]>();
const logged: Array<Record<string, unknown>> = [];
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<string, unknown>) => (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], [[], []]);
});
Loading