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:
@@ -18,6 +18,8 @@ All modules from the original plan are built:
|
||||
|
||||
- Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered)
|
||||
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
|
||||
(changes made in the app, sign-ins and sign-outs with the IP they came from, new accounts, and the log's own
|
||||
automatic trimming — attributed to "system"; settings changes show what changed, but never credentials)
|
||||
- **Dashboard** — an overview of every system this app tracks, all sharing
|
||||
one widget-card design (label + status badge, a small stat row, then its
|
||||
own breakdown): DNS (domain/record counts per provider, cached records by
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import * as client from "openid-client";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getOidcConfig } from "./oidc.js";
|
||||
import { upsertUserFromLogin } from "./users.js";
|
||||
import { env } from "../env.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
@@ -63,7 +67,28 @@ authRouter.get("/callback", async (req, res, next) => {
|
||||
// fall back to ID token claims already captured above
|
||||
}
|
||||
|
||||
await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||
const { user, created } = await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||
|
||||
// Access to the app is itself something worth being able to look back on: who got an account (and the very first
|
||||
// one becomes admin), and every sign-in with where it came from.
|
||||
if (created) {
|
||||
await recordAudit({
|
||||
actor: user,
|
||||
category: "user",
|
||||
action: "create",
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
detail: { name: user.name ?? user.email ?? user.oidcSub, role: user.role, firstUser: user.role === "admin" },
|
||||
});
|
||||
}
|
||||
await recordAudit({
|
||||
actor: user,
|
||||
category: "session",
|
||||
action: "login",
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
|
||||
});
|
||||
|
||||
delete req.session.pendingAuth;
|
||||
req.session.user = {
|
||||
@@ -85,6 +110,26 @@ authRouter.get("/callback", async (req, res, next) => {
|
||||
|
||||
authRouter.get("/logout", async (req, res, next) => {
|
||||
const idToken = req.session.user?.idToken;
|
||||
|
||||
// Recorded first, and never allowed to get in the way of signing out.
|
||||
if (req.session.user) {
|
||||
try {
|
||||
const [user] = await db.select().from(users).where(eq(users.oidcSub, req.session.user.sub)).limit(1);
|
||||
if (user) {
|
||||
await recordAudit({
|
||||
actor: user,
|
||||
category: "session",
|
||||
action: "logout",
|
||||
targetType: "user",
|
||||
targetId: user.id,
|
||||
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[auth] couldn't record sign-out:", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
let endSessionUrl: URL | undefined;
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function upsertUserFromLogin(params: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
}): Promise<{ user: typeof users.$inferSelect; created: boolean }> {
|
||||
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function upsertUserFromLogin(params: {
|
||||
})
|
||||
.where(eq(users.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
return { user: updated, created: false };
|
||||
}
|
||||
|
||||
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
||||
@@ -41,5 +41,5 @@ export async function upsertUserFromLogin(params: {
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
return { user: created, created: true };
|
||||
}
|
||||
|
||||
@@ -139,10 +139,18 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
let credentialId = existing.credentialId;
|
||||
let configJson = existing.config;
|
||||
let baseUrl = existing.baseUrl;
|
||||
// For the audit entry: what this edit actually changed. Names only for settings (operators can read the audit
|
||||
// log but not an integration's config), and never anything about the credentials beyond "they were replaced".
|
||||
let credentialsReplaced = false;
|
||||
let fieldsChanged: string[] = [];
|
||||
|
||||
if (parsed.data.config) {
|
||||
const loaded = await loadIntegrationConfig(id);
|
||||
const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config);
|
||||
credentialsReplaced = Object.keys(secretFields).length > 0;
|
||||
fieldsChanged = Object.keys(nonSecretFields).filter(
|
||||
(key) => String(loaded?.config[key] ?? "") !== String(nonSecretFields[key] ?? ""),
|
||||
);
|
||||
const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields };
|
||||
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
|
||||
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
|
||||
@@ -188,7 +196,13 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
action: "update",
|
||||
targetType: "integration",
|
||||
targetId: id,
|
||||
detail: { name: updated.name },
|
||||
detail: {
|
||||
name: updated.name,
|
||||
...(existing.name !== updated.name ? { renamedFrom: existing.name } : {}),
|
||||
...(existing.enabled !== updated.enabled ? { enabled: updated.enabled } : {}),
|
||||
credentialsReplaced,
|
||||
fieldsChanged,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||
import { describeSettingsChanges } from "../services/settingsDiff.js";
|
||||
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
||||
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
||||
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
|
||||
@@ -106,6 +107,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const before = await getSettings();
|
||||
const updated = await updateSettings(parsed.data);
|
||||
|
||||
if (parsed.data.notifications) {
|
||||
@@ -127,7 +129,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
category: "settings",
|
||||
action: "update",
|
||||
targetType: "settings",
|
||||
detail: { sections: Object.keys(parsed.data) },
|
||||
detail: { sections: Object.keys(parsed.data), changes: describeSettingsChanges(before, parsed.data) },
|
||||
});
|
||||
|
||||
res.json({ settings: updated });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -163,7 +163,10 @@ export default function Privacy() {
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Audit log</td>
|
||||
<td>Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.</td>
|
||||
<td>
|
||||
Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.
|
||||
Also each time you sign in or out — with the IP address you came from — and when your account was first created.
|
||||
</td>
|
||||
<td>
|
||||
{retention?.enabled
|
||||
? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).`
|
||||
@@ -288,7 +291,7 @@ export default function Privacy() {
|
||||
<div className="card-body">
|
||||
<ul className="mb-0">
|
||||
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
|
||||
<li className="mb-2">Operators and admins: also the audit log — who did what.</li>
|
||||
<li className="mb-2">Operators and admins: also the audit log — who did what, and who signed in from where.</li>
|
||||
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user