Files
Termix/src/backend/database/repositories/audit-log-repository.ts
T

368 lines
11 KiB
TypeScript

import {
and,
asc,
desc,
eq,
gte,
inArray,
isNull,
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;
// 2.9.1 logged these on every background plugin query and pushed real
// entries out of the capped log. They are now audited once per activation.
export const ROUTINE_PLUGIN_AUDIT_ACTIONS = [
"plugin_db_client",
"plugin_db_refs",
"plugin_kv_get",
"plugin_kv_list",
"plugin_files_data_dir",
"plugin_settings_read_core",
];
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<void>,
) {}
async create(entry: NewAuditLogRecord): Promise<void> {
await this.context.drizzle.insert(auditLogs).values(entry);
await this.pruneIfDue();
await this.afterWrite();
}
async listPage(input: {
filters: AuditLogFilters;
limit: number;
offset: number;
}): Promise<AuditLogPage> {
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<number>`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<AuditLogRecord[]> {
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<string[]> {
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<number> {
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<number> {
// 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);
}
async deleteRoutinePluginNoise(): Promise<number> {
AuditLogRepository.cachedCount = null;
const result = await this.context.drizzle
.delete(auditLogs)
.where(
and(
isNull(auditLogs.userId),
inArray(auditLogs.action, ROUTINE_PLUGIN_AUDIT_ACTIONS),
),
);
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<void> {
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<void> {
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<number> {
const result = await this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs);
return countValue(result[0]?.count);
}
/** Runs the prune regardless of the interval. Exposed for tests and startup. */
async pruneNow(): Promise<void> {
await this.pruneExpired();
await this.pruneOverflow();
AuditLogRepository.cachedCount = null;
}
/** Drops entries past the configured retention window. */
private async pruneExpired(): Promise<void> {
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<void> {
const max = auditMaxEntries();
const countResult = await this.context.drizzle
.select({ count: sql<number>`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<void> {
await this.onWrite?.();
}
}