import { and, asc, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm"; import { auditLogs } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; import { databaseLogger } from "../../utils/logger.js"; import { countValue, rowsAffected } from "./mutation-result.js"; export type AuditLogRecord = typeof auditLogs.$inferSelect; export type NewAuditLogRecord = typeof auditLogs.$inferInsert; export type AuditLogFilters = { userId?: string; action?: string; resourceType?: string; success?: boolean; startDate?: string; endDate?: string; }; export type AuditLogPage = { logs: AuditLogRecord[]; total: number; }; export const AUDIT_RETENTION_DAYS_ENV = "AUDIT_LOG_RETENTION_DAYS"; export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES"; const DEFAULT_MAX_ENTRIES = 10000; const PRUNE_TARGET_RATIO = 0.9; function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null { const raw = Number(env[key]); return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null; } /** * How long entries are kept. Unset means "no time limit", in which case only * the row cap applies. */ export function auditRetentionDays( env: NodeJS.ProcessEnv = process.env, ): number | null { return positiveIntEnv(AUDIT_RETENTION_DAYS_ENV, env); } /** Hard ceiling on stored entries, so a busy install cannot fill the disk. */ export function auditMaxEntries(env: NodeJS.ProcessEnv = process.env): number { return positiveIntEnv(AUDIT_MAX_ENTRIES_ENV, env) ?? DEFAULT_MAX_ENTRIES; } export class AuditLogRepository { /** * Cached row count backing the cap check. Static because the factory builds * a repository per call, so a per-instance count would never survive to be * reused. Null means "unknown, re-read" — which is also how any path that * deletes rows invalidates it. */ private static cachedCount: number | null = null; /** Drops the cached count so a test starts from a known state. */ static resetPruneThrottleForTests(): void { AuditLogRepository.cachedCount = null; } constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, ) {} async create(entry: NewAuditLogRecord): Promise { await this.context.drizzle.insert(auditLogs).values(entry); await this.pruneIfDue(); await this.afterWrite(); } async listPage(input: { filters: AuditLogFilters; limit: number; offset: number; }): Promise { const whereClause = this.buildWhere(input.filters); const [logs, totalResult] = await Promise.all([ this.context.drizzle .select() .from(auditLogs) .where(whereClause) .orderBy(desc(auditLogs.timestamp)) .limit(input.limit) .offset(input.offset), this.context.drizzle .select({ count: sql`COUNT(*)` }) .from(auditLogs) .where(whereClause), ]); return { logs, total: countValue(totalResult[0]?.count), }; } /** * Reads matching entries in ascending time order for export. * * Paged rather than fetched whole so an export cannot pull an unbounded * result set into memory, and ascending so a resumed or appended export * continues where the previous one stopped. */ async listForExport(input: { filters: AuditLogFilters; limit: number; offset: number; }): Promise { return this.context.drizzle .select() .from(auditLogs) .where(this.buildWhere(input.filters)) .orderBy(asc(auditLogs.timestamp), asc(auditLogs.id)) .limit(input.limit) .offset(input.offset); } async listDistinctActions(): Promise { const rows = await this.context.drizzle .selectDistinct({ action: auditLogs.action }) .from(auditLogs) .orderBy(asc(auditLogs.action)); return rows.map((row) => row.action); } /** * Detaches entries from a user being deleted instead of removing them. * * The schema already relaxed this foreign key to ON DELETE SET NULL, but the * account-deletion path deletes the rows explicitly, which undoes that. An * audit trail that vanishes with the account it recorded cannot answer the * question it exists for, and offboarding is exactly when that question gets * asked. `username` is denormalised, so the entry stays attributable. */ async anonymizeByUserId(userId: string): Promise { const result = await this.context.drizzle .update(auditLogs) .set({ userId: null }) .where(eq(auditLogs.userId, userId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } async deleteByUserId(userId: string): Promise { // Row count changed outside the insert path; force a re-read. AuditLogRepository.cachedCount = null; const result = await this.context.drizzle .delete(auditLogs) .where(eq(auditLogs.userId, userId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } private buildWhere(filters: AuditLogFilters) { const conditions = []; if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId)); if (filters.action) conditions.push(eq(auditLogs.action, filters.action)); if (filters.resourceType) { conditions.push(eq(auditLogs.resourceType, filters.resourceType)); } if (filters.success !== undefined) { conditions.push(eq(auditLogs.success, filters.success)); } if (filters.startDate) { conditions.push(gte(auditLogs.timestamp, filters.startDate)); } if (filters.endDate) { conditions.push(lte(auditLogs.timestamp, filters.endDate)); } return conditions.length > 0 ? and(...conditions) : undefined; } /** * Keeps the two prune passes off the per-write hot path without letting the * row cap go unenforced. * * Both passes used to run inline on every insert: a retention DELETE plus a * COUNT over the whole table, thousands of times an hour on a busy install, * almost always to find nothing to do — and audit writes sit in the request * path of the actions they record. * * They are split by what they cost and what they guarantee. Retention is * time-based, so nothing is lost by checking it on an interval. The row cap * is a disk-space guard that has to react to inserts, so it is still checked * on the write that crosses it — but against a cached count, so the common * case is an integer compare rather than a COUNT. */ private async pruneIfDue(): Promise { try { // Retention only costs anything on installs that configure it, and the // DELETE is driven by idx_audit_logs_timestamp, so it stays on the write // path where its "nothing older than N days survives" guarantee holds. await this.pruneExpired(); await this.pruneOverflowIfOverCap(); } catch (error) { // Pruning is maintenance; never fail the write that triggered it. databaseLogger.warn("Audit log prune failed", { operation: "audit_prune_failed", error: error instanceof Error ? error.message : String(error), }); } } /** * Enforces the cap using a cached row count, so a steady stream of writes * costs one COUNT to prime and then nothing until the cap is next reached. */ private async pruneOverflowIfOverCap(): Promise { const max = auditMaxEntries(); if (AuditLogRepository.cachedCount === null) { AuditLogRepository.cachedCount = await this.countAll(); } else { AuditLogRepository.cachedCount += 1; } if (AuditLogRepository.cachedCount < max) return; // At the cap: re-read for real, since the cached value can drift if rows // were deleted by another path (user deletion, manual cleanup). AuditLogRepository.cachedCount = await this.countAll(); if (AuditLogRepository.cachedCount < max) return; await this.pruneOverflow(); AuditLogRepository.cachedCount = await this.countAll(); } private async countAll(): Promise { const result = await this.context.drizzle .select({ count: sql`COUNT(*)` }) .from(auditLogs); return countValue(result[0]?.count); } /** Runs the prune regardless of the interval. Exposed for tests and startup. */ async pruneNow(): Promise { await this.pruneExpired(); await this.pruneOverflow(); AuditLogRepository.cachedCount = null; } /** Drops entries past the configured retention window. */ private async pruneExpired(): Promise { const days = auditRetentionDays(); if (days === null) return; const cutoff = sqlTimestampDaysAgo(days); const result = await this.context.drizzle .delete(auditLogs) .where(lt(auditLogs.timestamp, cutoff)); if (rowsAffected(result) > 0) { databaseLogger.info( `Pruned ${rowsAffected(result)} audit entries past retention`, { operation: "audit_retention_prune", removed: rowsAffected(result), retentionDays: days, cutoff, }, ); } } /** * Enforces the row cap. Unlike retention this discards entries that are still * within the window, so it is reported as a warning: it means the ceiling is * too low for how much this install audits, and evidence is being lost. */ private async pruneOverflow(): Promise { const max = auditMaxEntries(); const countResult = await this.context.drizzle .select({ count: sql`COUNT(*)` }) .from(auditLogs); const count = countValue(countResult[0]?.count); if (count < max) return; const deleteCount = count - Math.floor(max * PRUNE_TARGET_RATIO); const rows = await this.context.drizzle .select({ id: auditLogs.id, timestamp: auditLogs.timestamp }) .from(auditLogs) .orderBy(asc(auditLogs.timestamp)) .limit(deleteCount); if (rows.length === 0) return; await this.context.drizzle.delete(auditLogs).where( inArray( auditLogs.id, rows.map((row) => row.id), ), ); databaseLogger.warn( `Audit log hit its ${max}-entry cap; discarded ${rows.length} entries`, { operation: "audit_overflow_prune", removed: rows.length, maxEntries: max, oldestRemoved: rows[0]?.timestamp, newestRemoved: rows[rows.length - 1]?.timestamp, hint: `Raise ${AUDIT_MAX_ENTRIES_ENV}, or set ${AUDIT_RETENTION_DAYS_ENV} and export older entries before they are dropped.`, }, ); } private async afterWrite(): Promise { await this.onWrite?.(); } }