mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
🧹 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:
parent
ce6c79c9aa
commit
0e6bb20fbc
3 changed files with 60 additions and 65 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue