Record sign-ins, new accounts, automatic log trimming and what settings changed

A check of every write path found gaps in what the audit log captured:

- Sign-ins and sign-outs are now recorded with the IP they came from
  (sign-out is recorded first and can't block signing out).
- A new account is recorded when it's created on first sign-in, including
  when the very first user becomes admin - so the log shows who gained
  access, not only who changed things.
- The automatic log purge, which deletes audit entries, now records
  itself, attributed to "system". recordAudit() takes an optional actor
  for this. It only records when something was actually deleted.
- Settings updates record what changed (before and after) instead of only
  which sections were touched. The notification channels (Gotify, ntfy,
  SMTP, webhook) record field names only: they hold credentials, and
  webhook URLs and public ntfy topics act as secrets, while the audit log
  is readable by operators and Settings is admin-only.
- Integration edits record renames, enabling/disabling, whether
  credentials were replaced (never the credentials), and which settings
  fields changed (names only).

The Privacy page and README now say sign-ins store an IP in the audit log.

Verified through the real routes against a scratch database: user
creation, the logout route, the automatic purge, settings and
integration edits - including that a secret token and a webhook URL
appear nowhere in the stored entries. The sign-in callback itself needs
a real identity provider and wasn't run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5.5 committed 2026-10-02 22:40:46 +02:00
1 parent 22cfdbede0
commit 88d9c8e097
9 files changed
+129 -12

No files matched your search

+7 -4
View File
@@ -3,9 +3,12 @@ import { auditLog, users } from "../db/schema.js";
type CurrentUser = typeof users.$inferSelect;
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */
/** Who an automatic, no-one-clicked-anything entry is attributed to. */
export const SYSTEM_ACTOR_LABEL = "system";
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. Leave `actor` out for something the app did by itself. */
export async function recordAudit(params: {
actor: CurrentUser;
actor?: CurrentUser;
category: string;
action: string;
targetType?: string;
@@ -13,8 +16,8 @@ export async function recordAudit(params: {
detail?: unknown;
}) {
await db.insert(auditLog).values({
actorUserId: params.actor.id,
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub,
actorUserId: params.actor?.id,
actorLabel: params.actor ? (params.actor.name ?? params.actor.email ?? params.actor.oidcSub) : SYSTEM_ACTOR_LABEL,
category: params.category,
action: params.action,
targetType: params.targetType,
@@ -1,5 +1,6 @@
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
import { purgeOldLogs } from "./logRetention.js";
import { recordAudit } from "./audit.js";
const LAST_RUN_FLAG = "logRetentionLastRunAt";
@@ -9,6 +10,12 @@ async function runPurge(): Promise<void> {
const result = await purgeOldLogs(logRetention.retentionDays);
await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString());
if (result.diagDeleted || result.auditDeleted) {
// Trimming the audit log is itself something to be able to look back on — attributed to the system, since no one asked for it.
await recordAudit({
category: "settings",
action: "purge_logs",
detail: { automatic: true, retentionDays: logRetention.retentionDays, ...result },
});
console.log(
`[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`,
);
+41
View File
@@ -0,0 +1,41 @@
/**
* What a settings update actually changed, in a form fit for the audit log.
*
* The audit log can be read by operators, but Settings can't — so values only go in for sections an operator could
* already see in the app. The notification channels (Gotify, ntfy, SMTP, webhook) hold credentials, and even their
* addresses can act as one (a webhook URL carries its own token; a public ntfy topic is the only thing protecting
* it), so for those only the names of the fields that changed are recorded, never what they changed to or from.
*/
const NAMES_ONLY_SECTIONS = new Set(["gotify", "ntfy", "smtp", "webhook"]);
/** Anything longer than this isn't a useful thing to read in a table cell. */
const MAX_VALUE_JSON = 300;
export type SettingsChange = { from: unknown; to: unknown } | "(changed)";
function same(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function capture(value: unknown): unknown {
return value !== undefined && JSON.stringify(value)?.length > MAX_VALUE_JSON ? "(too long to show)" : value;
}
/**
* Compares each section in `patch` with what was stored before. Only fields that really differ are listed, and a
* section where nothing differed is left out entirely.
*/
export function describeSettingsChanges(before: object, patch: object): Record<string, Record<string, SettingsChange>> {
const out: Record<string, Record<string, SettingsChange>> = {};
for (const [section, incoming] of Object.entries(patch)) {
if (incoming === null || typeof incoming !== "object") continue;
const previous = ((before as Record<string, unknown>)[section] ?? {}) as Record<string, unknown>;
const changes: Record<string, SettingsChange> = {};
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
if (same(previous[key], value)) continue;
changes[key] = NAMES_ONLY_SECTIONS.has(section) ? "(changed)" : { from: capture(previous[key]), to: capture(value) };
}
if (Object.keys(changes).length > 0) out[section] = changes;
}
return out;
}