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:
1 parent
22cfdbede0
commit
88d9c8e097
9 files changed
+129
-12
No files matched your search
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user