🧹 chore: Audit log backend cleanup — offset pagination, name-based filters, type tightening

Switch listAuditLogPage from cursor-based to offset-based pagination with skip().limit() + parallel countDocuments, returning { entries, total } instead of { entries, nextCursor }; the cursor encode and decode helpers are no longer needed and have been removed.

Interpret the actorId and targetPrincipalId filter parameters as case-insensitive partial regex against the denormalized actorName and targetName fields rather than exact-match against the underlying ObjectId. Admin panel users naturally filter by human name, not by Mongo identifier.

Replace the broad Record<string, unknown> casts on req.query with a typed AuditLogQuery shape, drop two unused exported types and the now-unused mongoose Types import, and fix the streamAuditLogEntries Omit literal to match the interface and the offset-based design.
This commit is contained in:
Dustin Healy 2026-05-11 22:52:44 -07:00
parent ce6c79c9aa
commit 0e6bb20fbc
3 changed files with 60 additions and 65 deletions

View file

@ -7,7 +7,6 @@ import type {
RecordAuditEntryInput,
} from '@librechat/data-schemas';
import type { Response } from 'express';
import type { Types } from 'mongoose';
import type { ServerRequest } from '~/types/http';
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
@ -32,7 +31,7 @@ export interface AdminAuditLogDeps {
targetPrincipalType?: PrincipalType;
targetPrincipalId?: string;
capability?: string;
cursor?: string;
offset?: number;
limit?: number;
},
) => Promise<AuditLogPage>;
@ -84,7 +83,9 @@ function parseIsoDate(raw: unknown): { ok: true; value?: Date } | { ok: false; e
return { ok: true, value: d };
}
function parseLimit(raw: unknown): { ok: true; value: number | undefined } | { ok: false; error: string } {
function parseLimit(
raw: unknown,
): { ok: true; value: number | undefined } | { ok: false; error: string } {
if (raw == null || raw === '') return { ok: true, value: undefined };
const n = typeof raw === 'number' ? raw : Number.parseInt(String(raw), 10);
if (!Number.isFinite(n)) return { ok: false, error: 'limit must be a number' };
@ -93,6 +94,16 @@ function parseLimit(raw: unknown): { ok: true; value: number | undefined } | { o
return { ok: true, value: Math.floor(n) };
}
function parseOffset(
raw: unknown,
): { ok: true; value: number | undefined } | { ok: false; error: string } {
if (raw == null || raw === '') return { ok: true, value: undefined };
const n = typeof raw === 'number' ? raw : Number.parseInt(String(raw), 10);
if (!Number.isFinite(n)) return { ok: false, error: 'offset must be a number' };
if (n < 0) return { ok: false, error: 'offset must be >= 0' };
return { ok: true, value: Math.floor(n) };
}
function parsePrincipalType(raw: unknown): PrincipalType | undefined {
if (typeof raw !== 'string' || !raw) return undefined;
if (!VALID_PRINCIPAL_TYPES.has(raw)) return undefined;
@ -145,8 +156,21 @@ interface ParsedFilters {
capability?: string;
}
interface AuditLogQuery {
search?: string;
action?: string | string[];
from?: string;
to?: string;
actorId?: string;
targetPrincipalType?: string;
targetPrincipalId?: string;
capability?: string;
limit?: string;
offset?: string;
}
function parseFilters(
query: Record<string, unknown>,
query: AuditLogQuery,
): { ok: true; value: ParsedFilters } | { ok: false; error: string } {
const from = parseIsoDate(query.from);
if (!from.ok) return { ok: false, error: `from: ${from.error}` };
@ -175,16 +199,18 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps) {
const caller = resolveCaller(req);
if (!caller) return res.status(401).json({ error: 'Authentication required' });
const filters = parseFilters(req.query as Record<string, unknown>);
const query = req.query as AuditLogQuery;
const filters = parseFilters(query);
if (!filters.ok) return res.status(400).json({ error: filters.error });
const cursor = pickString((req.query as Record<string, unknown>).cursor, 256);
const limitResult = parseLimit((req.query as Record<string, unknown>).limit);
const limitResult = parseLimit(query.limit);
if (!limitResult.ok) return res.status(400).json({ error: limitResult.error });
const offsetResult = parseOffset(query.offset);
if (!offsetResult.ok) return res.status(400).json({ error: offsetResult.error });
const page = await listAuditLogPage(caller.tenantId, {
...filters.value,
cursor,
offset: offsetResult.value,
limit: limitResult.value,
});
@ -219,15 +245,12 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps) {
const caller = resolveCaller(req);
if (!caller) return res.status(401).json({ error: 'Authentication required' });
const filters = parseFilters(req.query as Record<string, unknown>);
const filters = parseFilters(req.query as AuditLogQuery);
if (!filters.ok) return res.status(400).json({ error: filters.error });
const filenameStamp = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader(
'Content-Disposition',
`attachment; filename="audit-log-${filenameStamp}.csv"`,
);
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${filenameStamp}.csv"`);
res.setHeader('Cache-Control', 'no-store');
res.write(CSV_BOM);
@ -255,15 +278,3 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps) {
exportAuditLogCsv: exportAuditLogCsvHandler,
};
}
export type ResolveAuditNamesInput = {
caller: { userId: string; role: string; tenantId?: string };
targetPrincipalType: PrincipalType;
targetPrincipalId: string;
};
export type AuditNameResolution = {
actorId: string | Types.ObjectId;
actorName: string;
targetName: string;
};

View file

@ -117,7 +117,9 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps) {
}): Promise<void> {
if (!recordAuditEntry) return;
try {
const actorName = resolveUserName ? (await resolveUserName(args.caller.userId)) ?? args.caller.userId : args.caller.userId;
const actorName = resolveUserName
? ((await resolveUserName(args.caller.userId)) ?? args.caller.userId)
: args.caller.userId;
// For ROLE principals the principalId IS the human-readable name; for USER/GROUP
// the same string is the id, and the display name lookup happens in a later iteration.
const targetName = args.principalId;

View file

@ -27,13 +27,13 @@ export interface AuditLogFilters {
targetPrincipalType?: PrincipalType;
targetPrincipalId?: string;
capability?: string;
cursor?: string;
offset?: number;
limit?: number;
}
export interface AuditLogPage {
entries: AdminAuditLogEntryWire[];
nextCursor: string | null;
total: number;
}
/**
@ -64,7 +64,7 @@ export interface AuditLogMethods {
) => Promise<AdminAuditLogEntryWire | null>;
streamAuditLogEntries: (
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'cursor' | 'limit'>,
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
) => Promise<number>;
}
@ -87,20 +87,6 @@ function toWire(doc: IAuditLog): AdminAuditLogEntryWire {
};
}
function encodeCursor(id: Types.ObjectId): string {
return Buffer.from(id.toString(), 'utf8').toString('base64url');
}
function decodeCursor(cursor: string): string | null {
try {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
if (!/^[a-fA-F0-9]{24}$/.test(decoded)) return null;
return decoded;
} catch {
return null;
}
}
function tenantFilter(tenantId?: string): FilterQuery<IAuditLog> {
return tenantId != null ? { tenantId } : { tenantId: { $exists: false } };
}
@ -114,17 +100,20 @@ function buildFilter(
if (filters.action && filters.action.length > 0) {
query.action = filters.action.length === 1 ? filters.action[0] : { $in: filters.action };
}
// The `actorId` and `targetPrincipalId` filter params are matched against the
// denormalized `actorName` / `targetName` fields with case-insensitive partial
// regex — UI users want to filter by human name, not by Mongo ObjectId.
if (filters.actorId) {
query.actorId = filters.actorId;
query.actorName = { $regex: escapeRegex(filters.actorId), $options: 'i' };
}
if (filters.targetPrincipalType) {
query.targetPrincipalType = filters.targetPrincipalType;
}
if (filters.targetPrincipalId) {
query.targetPrincipalId = filters.targetPrincipalId;
query.targetName = { $regex: escapeRegex(filters.targetPrincipalId), $options: 'i' };
}
if (filters.capability) {
query.capability = filters.capability;
query.capability = { $regex: escapeRegex(filters.capability), $options: 'i' };
}
if (filters.from || filters.to) {
query.createdAt = {};
@ -180,28 +169,21 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
): Promise<AuditLogPage> {
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
const limit = clampLimit(filters.limit);
const offset = filters.offset && filters.offset > 0 ? Math.floor(filters.offset) : 0;
const query = buildFilter(tenantId, filters);
if (filters.cursor) {
const cursorId = decodeCursor(filters.cursor);
if (cursorId) {
query._id = { $lt: cursorId };
}
}
const rows = await AuditLog.find(query)
.sort({ createdAt: -1, _id: -1 })
.limit(limit + 1)
.lean<IAuditLog[]>();
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const last = page[page.length - 1];
const nextCursor = hasMore && last ? encodeCursor(last._id) : null;
const [rows, total] = await Promise.all([
AuditLog.find(query)
.sort({ createdAt: -1, _id: -1 })
.skip(offset)
.limit(limit)
.lean<IAuditLog[]>(),
AuditLog.countDocuments(query),
]);
return {
entries: page.map(toWire),
nextCursor,
entries: rows.map(toWire),
total,
};
}
@ -218,7 +200,7 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
async function streamAuditLogEntries(
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'cursor' | 'limit'>,
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
): Promise<number> {
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;