mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
368 lines
11 KiB
TypeScript
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?.();
|
|
}
|
|
}
|