mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 02:26:20 +00:00
🛠️ fix: Address P1 audit-log review findings (cursor cancel, drain race, filter naming, type dedupe, tenant scope, log enrichment)
The CSV stream handler kept draining Mongo batches after the client
disconnected because the `for await` loop only honored its abort flag
inside `onEntry`. Thread an `isCancelled` callback into
`streamAuditLogEntries` so the methods layer closes the cursor as soon
as the handler sees `close`/`aborted`; a `finally` block guarantees
release on throw. The drain promise in `writeChunk` now races against
the response's `close` event so a destroyed socket cannot strand the
handler on a `drain` that will never fire.
The HTTP filter keys `actorId` and `targetPrincipalId` always did
case-insensitive substring matches on the denormalized `actorName` /
`targetName` columns, never on ObjectIds — a client passing a real id
silently got zero rows. Renamed the wire-level keys to `actorQuery` /
`targetQuery` (matching what the matcher actually does) and kept the
old names as deprecated aliases for one release so the sibling
admin-panel PR can migrate without breaking; each legacy use logs a
deprecation warning. Renamed the corresponding fields in
`AuditLogFilters` too.
`AdminAuditLogEntryWire` duplicated `AdminAuditLogEntry` from
`types/admin.ts` field-for-field, violating the no-duplicate-types
rule. Deleted the duplicate, hoisted `AuditLogPage`,
`RecordAuditEntryInput`, and `AuditLogFilters` from
`methods/auditLog.ts` into `types/auditLog.ts`, and updated the
handler, method factory, and re-exports accordingly.
`tenantFilter` treated `''` as a valid tenant scope, producing a
`{ tenantId: '' }` query that silently returned nothing while the
schema validator rejected `''` on writes. Switched to a strict
`typeof tenantId === 'string' && tenantId.trim().length > 0` check so
reads agree with writes, with new spec coverage for empty and
whitespace-only inputs.
Audit-write failures now log the full forensic payload (action,
capability, tenantId, actorId, target metadata) inside a single meta
object so winston's standard signature surfaces it correctly; a comment
on the catch block explains why the failure mode stays silent (it must
never block a privileged operation).
Stronger filter parsing: invalid `action` values and unknown
`targetPrincipalType` now return 400 instead of silently dropping.
Extracted `MAX_LIMIT` to a constant. Replaced the
`Record<string, Date>` cast in `buildFilter` with a typed local.
Switched the stream cursor to `lean<IAuditLog[]>()` and removed the
`as IAuditLog` cast inside the loop.
This commit is contained in:
parent
bed3bc95b6
commit
73ecf162fd
5 changed files with 258 additions and 136 deletions
|
|
@ -1,6 +1,11 @@
|
|||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { AdminAuditLogEntryWire, AuditAction, AuditLogPage } from '@librechat/data-schemas';
|
||||
import type {
|
||||
AdminAuditLogEntry,
|
||||
AuditAction,
|
||||
AuditLogFilters,
|
||||
AuditLogPage,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
|
|
@ -10,37 +15,30 @@ const CSV_BOM = '';
|
|||
const VALID_ACTIONS = new Set<AuditAction>(['grant_assigned', 'grant_removed']);
|
||||
const VALID_PRINCIPAL_TYPES = new Set<string>(Object.values(PrincipalType));
|
||||
|
||||
// Accepts `YYYY-MM-DD` (interpreted as UTC by the downstream Date parse) or a
|
||||
// full ISO 8601 / RFC 3339 timestamp that includes either `Z` or a `±HH:MM`
|
||||
// offset. Local-time strings without a zone are rejected so every input maps
|
||||
// to an unambiguous instant.
|
||||
/**
|
||||
* Accepts `YYYY-MM-DD` (interpreted as UTC by the downstream Date parse) or a
|
||||
* full ISO 8601 / RFC 3339 timestamp that includes either `Z` or a `±HH:MM`
|
||||
* offset. Local-time strings without a zone are rejected so every input maps
|
||||
* to an unambiguous instant.
|
||||
*/
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2}))?$/;
|
||||
const OBJECT_ID_RE = /^[a-fA-F0-9]{24}$/;
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export interface AdminAuditLogDeps {
|
||||
listAuditLogPage: (
|
||||
tenantId: string | undefined,
|
||||
filters: {
|
||||
search?: string;
|
||||
action?: AuditAction[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
actorId?: string;
|
||||
targetPrincipalType?: PrincipalType;
|
||||
targetPrincipalId?: string;
|
||||
capability?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
},
|
||||
filters: AuditLogFilters,
|
||||
) => Promise<AuditLogPage>;
|
||||
findAuditLogEntry: (
|
||||
tenantId: string | undefined,
|
||||
id: string,
|
||||
) => Promise<AdminAuditLogEntryWire | null>;
|
||||
) => Promise<AdminAuditLogEntry | null>;
|
||||
streamAuditLogEntries: (
|
||||
tenantId: string | undefined,
|
||||
filters: Parameters<AdminAuditLogDeps['listAuditLogPage']>[1],
|
||||
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
|
||||
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
|
||||
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
|
||||
options?: { isCancelled?: () => boolean },
|
||||
) => Promise<number>;
|
||||
}
|
||||
|
||||
|
|
@ -65,11 +63,19 @@ function asStringArray(v: unknown): string[] | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function parseActionFilter(raw: unknown): AuditAction[] | undefined {
|
||||
function parseActionFilter(raw: unknown):
|
||||
| {
|
||||
ok: true;
|
||||
value: AuditAction[] | undefined;
|
||||
}
|
||||
| { ok: false; error: string } {
|
||||
const arr = asStringArray(raw);
|
||||
if (!arr || arr.length === 0) return undefined;
|
||||
const valid = arr.filter((a): a is AuditAction => VALID_ACTIONS.has(a as AuditAction));
|
||||
return valid.length ? valid : undefined;
|
||||
if (!arr || arr.length === 0) return { ok: true, value: undefined };
|
||||
const invalid = arr.find((a) => !VALID_ACTIONS.has(a as AuditAction));
|
||||
if (invalid != null) {
|
||||
return { ok: false, error: `Unknown action: ${invalid}` };
|
||||
}
|
||||
return { ok: true, value: arr as AuditAction[] };
|
||||
}
|
||||
|
||||
function parseIsoDate(raw: unknown): { ok: true; value?: Date } | { ok: false; error: string } {
|
||||
|
|
@ -88,7 +94,7 @@ function parseLimit(
|
|||
const n = typeof raw === 'number' ? raw : Number.parseInt(String(raw), 10);
|
||||
if (!Number.isFinite(n)) return { ok: false, error: 'limit must be a number' };
|
||||
if (n < 1) return { ok: false, error: 'limit must be >= 1' };
|
||||
if (n > 500) return { ok: false, error: 'limit must be <= 500' };
|
||||
if (n > MAX_LIMIT) return { ok: false, error: `limit must be <= ${MAX_LIMIT}` };
|
||||
return { ok: true, value: Math.floor(n) };
|
||||
}
|
||||
|
||||
|
|
@ -102,10 +108,15 @@ function parseOffset(
|
|||
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;
|
||||
return raw as PrincipalType;
|
||||
function parsePrincipalType(
|
||||
raw: unknown,
|
||||
): { ok: true; value: PrincipalType | undefined } | { ok: false; error: string } {
|
||||
if (raw == null || raw === '') return { ok: true, value: undefined };
|
||||
if (typeof raw !== 'string') return { ok: false, error: 'targetPrincipalType must be a string' };
|
||||
if (!VALID_PRINCIPAL_TYPES.has(raw)) {
|
||||
return { ok: false, error: `Unknown targetPrincipalType: ${raw}` };
|
||||
}
|
||||
return { ok: true, value: raw as PrincipalType };
|
||||
}
|
||||
|
||||
function pickString(raw: unknown, maxLen = 256): string | undefined {
|
||||
|
|
@ -124,7 +135,7 @@ function escapeCsvCell(value: string): string {
|
|||
return guarded;
|
||||
}
|
||||
|
||||
const CSV_COLUMNS: ReadonlyArray<{ key: keyof AdminAuditLogEntryWire; label: string }> = [
|
||||
const CSV_COLUMNS: ReadonlyArray<{ key: keyof AdminAuditLogEntry; label: string }> = [
|
||||
{ key: 'timestamp', label: 'Timestamp' },
|
||||
{ key: 'action', label: 'Action' },
|
||||
{ key: 'actorName', label: 'Actor' },
|
||||
|
|
@ -139,7 +150,7 @@ function formatCsvHeader(): string {
|
|||
return CSV_COLUMNS.map((c) => escapeCsvCell(c.label)).join(',');
|
||||
}
|
||||
|
||||
function formatCsvRow(entry: AdminAuditLogEntryWire): string {
|
||||
function formatCsvRow(entry: AdminAuditLogEntry): string {
|
||||
return CSV_COLUMNS.map((c) => escapeCsvCell(String(entry[c.key] ?? ''))).join(',');
|
||||
}
|
||||
|
||||
|
|
@ -148,9 +159,9 @@ interface ParsedFilters {
|
|||
action?: AuditAction[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
actorId?: string;
|
||||
actorQuery?: string;
|
||||
targetPrincipalType?: PrincipalType;
|
||||
targetPrincipalId?: string;
|
||||
targetQuery?: string;
|
||||
capability?: string;
|
||||
}
|
||||
|
||||
|
|
@ -159,14 +170,50 @@ interface AuditLogQuery {
|
|||
action?: string | string[];
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Substring match against the denormalized actor display name. */
|
||||
actorQuery?: string;
|
||||
/** @deprecated Use `actorQuery`. Still accepted as an alias. */
|
||||
actorId?: string;
|
||||
targetPrincipalType?: string;
|
||||
/** Substring match against the denormalized target display name. */
|
||||
targetQuery?: string;
|
||||
/** @deprecated Use `targetQuery`. Still accepted as an alias. */
|
||||
targetPrincipalId?: string;
|
||||
capability?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP filter keys `actorId`/`targetPrincipalId` were misnomers — they
|
||||
* never matched ObjectIds, they did case-insensitive substring matches on
|
||||
* the denormalized display names. The new keys `actorQuery`/`targetQuery`
|
||||
* describe what actually happens. The legacy names are accepted for one
|
||||
* release as deprecated aliases so the sibling admin-panel PR keeps working
|
||||
* while it migrates; each use emits a deprecation log.
|
||||
*/
|
||||
function readActorQuery(query: AuditLogQuery): string | undefined {
|
||||
if (query.actorQuery != null) return pickString(query.actorQuery, 128);
|
||||
if (query.actorId != null) {
|
||||
logger.warn(
|
||||
'[adminAuditLog] deprecated filter param `actorId` — rename to `actorQuery` (substring match on actorName)',
|
||||
);
|
||||
return pickString(query.actorId, 128);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readTargetQuery(query: AuditLogQuery): string | undefined {
|
||||
if (query.targetQuery != null) return pickString(query.targetQuery, 128);
|
||||
if (query.targetPrincipalId != null) {
|
||||
logger.warn(
|
||||
'[adminAuditLog] deprecated filter param `targetPrincipalId` — rename to `targetQuery` (substring match on targetName)',
|
||||
);
|
||||
return pickString(query.targetPrincipalId, 128);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseFilters(
|
||||
query: AuditLogQuery,
|
||||
): { ok: true; value: ParsedFilters } | { ok: false; error: string } {
|
||||
|
|
@ -174,16 +221,20 @@ function parseFilters(
|
|||
if (!from.ok) return { ok: false, error: `from: ${from.error}` };
|
||||
const to = parseIsoDate(query.to);
|
||||
if (!to.ok) return { ok: false, error: `to: ${to.error}` };
|
||||
const action = parseActionFilter(query.action);
|
||||
if (!action.ok) return { ok: false, error: action.error };
|
||||
const targetPrincipalType = parsePrincipalType(query.targetPrincipalType);
|
||||
if (!targetPrincipalType.ok) return { ok: false, error: targetPrincipalType.error };
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
search: pickString(query.search, 200),
|
||||
action: parseActionFilter(query.action),
|
||||
action: action.value,
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
actorId: pickString(query.actorId, 128),
|
||||
targetPrincipalType: parsePrincipalType(query.targetPrincipalType),
|
||||
targetPrincipalId: pickString(query.targetPrincipalId, 128),
|
||||
actorQuery: readActorQuery(query),
|
||||
targetPrincipalType: targetPrincipalType.value,
|
||||
targetQuery: readTargetQuery(query),
|
||||
capability: pickString(query.capability, 256),
|
||||
},
|
||||
};
|
||||
|
|
@ -251,32 +302,57 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps) {
|
|||
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${filenameStamp}.csv"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
// Stop pulling rows from Mongo as soon as the client disconnects so a
|
||||
// cancelled download doesn't pin a cursor open or burn through memory.
|
||||
/**
|
||||
* The socket-`close` listener is the canonical signal — it fires on
|
||||
* client TCP RST as well as on graceful end. `req.aborted` (deprecated)
|
||||
* is kept as a belt-and-braces fallback for Node versions that emit it
|
||||
* before the response sees `close`.
|
||||
*/
|
||||
let clientAborted = false;
|
||||
const markAborted = () => {
|
||||
clientAborted = true;
|
||||
};
|
||||
res.once('close', markAborted);
|
||||
req.once('aborted', markAborted);
|
||||
const isCancelled = () => clientAborted;
|
||||
|
||||
// Wait for `drain` when the kernel/socket buffer is full so we never
|
||||
// queue an unbounded amount of CSV in Node memory for slow consumers.
|
||||
/**
|
||||
* Wait for `drain` when the kernel/socket buffer is full so we never
|
||||
* queue an unbounded amount of CSV in Node memory for slow consumers.
|
||||
* Race against `close` so a destroyed socket can't strand the handler
|
||||
* on a `drain` that will never fire.
|
||||
*/
|
||||
const writeChunk = (chunk: string): Promise<void> => {
|
||||
if (clientAborted) return Promise.resolve();
|
||||
if (res.write(chunk)) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => res.once('drain', () => resolve()));
|
||||
return new Promise<void>((resolve) => {
|
||||
const onDrain = () => {
|
||||
res.removeListener('close', onClose);
|
||||
resolve();
|
||||
};
|
||||
const onClose = () => {
|
||||
res.removeListener('drain', onDrain);
|
||||
resolve();
|
||||
};
|
||||
res.once('drain', onDrain);
|
||||
res.once('close', onClose);
|
||||
});
|
||||
};
|
||||
|
||||
await writeChunk(CSV_BOM);
|
||||
await writeChunk(formatCsvHeader());
|
||||
await writeChunk('\r\n');
|
||||
|
||||
await streamAuditLogEntries(caller.tenantId, filters.value, async (entry) => {
|
||||
if (clientAborted) return;
|
||||
await writeChunk(formatCsvRow(entry));
|
||||
await writeChunk('\r\n');
|
||||
});
|
||||
await streamAuditLogEntries(
|
||||
caller.tenantId,
|
||||
filters.value,
|
||||
async (entry) => {
|
||||
if (clientAborted) return;
|
||||
await writeChunk(formatCsvRow(entry));
|
||||
await writeChunk('\r\n');
|
||||
},
|
||||
{ isCancelled },
|
||||
);
|
||||
|
||||
res.removeListener('close', markAborted);
|
||||
req.removeListener('aborted', markAborted);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import mongoose, { Types } from 'mongoose';
|
|||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type * as t from '~/types';
|
||||
import { createAuditLogMethods, type RecordAuditEntryInput } from './auditLog';
|
||||
import type { RecordAuditEntryInput } from '~/types';
|
||||
import { createAuditLogMethods } from './auditLog';
|
||||
import auditLogSchema from '~/schema/auditLog';
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
|
|
@ -106,13 +107,32 @@ describe('auditLog methods', () => {
|
|||
expect(page.total).toBe(2);
|
||||
});
|
||||
|
||||
it('partial-matches the actorId param against actorName', async () => {
|
||||
it('partial-matches the actorQuery param against actorName', async () => {
|
||||
await methods.recordAuditEntry(baseInput({ actorName: 'Charlotte Auditor' }));
|
||||
const page = await methods.listAuditLogPage('tenant-a', { actorId: 'charlotte' });
|
||||
const page = await methods.listAuditLogPage('tenant-a', { actorQuery: 'charlotte' });
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.entries[0]?.actorName).toBe('Charlotte Auditor');
|
||||
});
|
||||
|
||||
it('partial-matches the targetQuery param against targetName', async () => {
|
||||
await methods.recordAuditEntry(baseInput({ targetName: 'Daphne Director' }));
|
||||
const page = await methods.listAuditLogPage('tenant-a', { targetQuery: 'daphne' });
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.entries[0]?.targetName).toBe('Daphne Director');
|
||||
});
|
||||
|
||||
it('treats empty-string tenantId as platform-level (not a literal `""` tenant match)', async () => {
|
||||
const page = await methods.listAuditLogPage('', {});
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.entries[0]?.capability).toBe('access:admin');
|
||||
});
|
||||
|
||||
it('treats whitespace-only tenantId as platform-level', async () => {
|
||||
const page = await methods.listAuditLogPage(' ', {});
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.entries[0]?.capability).toBe('access:admin');
|
||||
});
|
||||
|
||||
it('partial-matches the capability filter case-insensitively', async () => {
|
||||
const page = await methods.listAuditLogPage('tenant-a', { capability: 'ROLES' });
|
||||
expect(page.total).toBe(1);
|
||||
|
|
@ -167,5 +187,26 @@ describe('auditLog methods', () => {
|
|||
expect(count).toBe(2);
|
||||
expect(new Set(collected)).toEqual(new Set(['manage:users', 'manage:roles']));
|
||||
});
|
||||
|
||||
it('stops iterating and closes the cursor when isCancelled becomes true', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await methods.recordAuditEntry(baseInput({ capability: `manage:cap-${i}` }));
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const seen: string[] = [];
|
||||
const count = await methods.streamAuditLogEntries(
|
||||
'tenant-a',
|
||||
{},
|
||||
(entry) => {
|
||||
seen.push(entry.capability);
|
||||
if (seen.length >= 3) cancelled = true;
|
||||
},
|
||||
{ isCancelled: () => cancelled },
|
||||
);
|
||||
|
||||
expect(count).toBe(3);
|
||||
expect(seen.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,57 +1,17 @@
|
|||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, Types } from 'mongoose';
|
||||
import type { AuditAction, IAuditLog } from '~/types';
|
||||
import type { FilterQuery, Model } from 'mongoose';
|
||||
import type {
|
||||
AdminAuditLogEntry,
|
||||
AuditLogFilters,
|
||||
AuditLogPage,
|
||||
IAuditLog,
|
||||
RecordAuditEntryInput,
|
||||
} from '~/types';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 500;
|
||||
export const MAX_LIMIT = 500;
|
||||
const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
export interface RecordAuditEntryInput {
|
||||
action: AuditAction;
|
||||
actorId: string | Types.ObjectId;
|
||||
actorName: string;
|
||||
targetPrincipalType: PrincipalType;
|
||||
targetPrincipalId: string | Types.ObjectId;
|
||||
targetName: string;
|
||||
capability: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
search?: string;
|
||||
action?: AuditAction[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
actorId?: string;
|
||||
targetPrincipalType?: PrincipalType;
|
||||
targetPrincipalId?: string;
|
||||
capability?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogPage {
|
||||
entries: AdminAuditLogEntryWire[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire shape returned to admin clients. `_id` is mapped to `id`, ObjectIds are
|
||||
* stringified, dates are ISO strings.
|
||||
*/
|
||||
export interface AdminAuditLogEntryWire {
|
||||
id: string;
|
||||
action: AuditAction;
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
targetPrincipalType: PrincipalType;
|
||||
targetPrincipalId: string;
|
||||
targetName: string;
|
||||
capability: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AuditLogMethods {
|
||||
recordAuditEntry: (input: RecordAuditEntryInput) => Promise<IAuditLog | null>;
|
||||
listAuditLogPage: (
|
||||
|
|
@ -61,11 +21,12 @@ export interface AuditLogMethods {
|
|||
findAuditLogEntry: (
|
||||
tenantId: string | undefined,
|
||||
id: string,
|
||||
) => Promise<AdminAuditLogEntryWire | null>;
|
||||
) => Promise<AdminAuditLogEntry | null>;
|
||||
streamAuditLogEntries: (
|
||||
tenantId: string | undefined,
|
||||
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
|
||||
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
|
||||
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
|
||||
options?: { isCancelled?: () => boolean },
|
||||
) => Promise<number>;
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +34,7 @@ function escapeRegex(input: string): string {
|
|||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function toWire(doc: IAuditLog): AdminAuditLogEntryWire {
|
||||
function toWire(doc: IAuditLog): AdminAuditLogEntry {
|
||||
return {
|
||||
id: doc._id.toString(),
|
||||
action: doc.action,
|
||||
|
|
@ -88,7 +49,9 @@ function toWire(doc: IAuditLog): AdminAuditLogEntryWire {
|
|||
}
|
||||
|
||||
function tenantFilter(tenantId?: string): FilterQuery<IAuditLog> {
|
||||
return tenantId != null ? { tenantId } : { tenantId: { $exists: false } };
|
||||
return typeof tenantId === 'string' && tenantId.trim().length > 0
|
||||
? { tenantId }
|
||||
: { tenantId: { $exists: false } };
|
||||
}
|
||||
|
||||
function buildFilter(
|
||||
|
|
@ -100,25 +63,23 @@ 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.actorName = { $regex: escapeRegex(filters.actorId), $options: 'i' };
|
||||
if (filters.actorQuery) {
|
||||
query.actorName = { $regex: escapeRegex(filters.actorQuery), $options: 'i' };
|
||||
}
|
||||
if (filters.targetPrincipalType) {
|
||||
query.targetPrincipalType = filters.targetPrincipalType;
|
||||
}
|
||||
if (filters.targetPrincipalId) {
|
||||
query.targetName = { $regex: escapeRegex(filters.targetPrincipalId), $options: 'i' };
|
||||
if (filters.targetQuery) {
|
||||
query.targetName = { $regex: escapeRegex(filters.targetQuery), $options: 'i' };
|
||||
}
|
||||
if (filters.capability) {
|
||||
query.capability = { $regex: escapeRegex(filters.capability), $options: 'i' };
|
||||
}
|
||||
if (filters.from || filters.to) {
|
||||
query.createdAt = {};
|
||||
if (filters.from) (query.createdAt as Record<string, Date>).$gte = filters.from;
|
||||
if (filters.to) (query.createdAt as Record<string, Date>).$lte = filters.to;
|
||||
const createdAt: { $gte?: Date; $lte?: Date } = {};
|
||||
if (filters.from) createdAt.$gte = filters.from;
|
||||
if (filters.to) createdAt.$lte = filters.to;
|
||||
query.createdAt = createdAt;
|
||||
}
|
||||
if (filters.search && filters.search.length > 0) {
|
||||
const trimmed = filters.search.slice(0, MAX_SEARCH_LENGTH);
|
||||
|
|
@ -154,11 +115,25 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
|
|||
});
|
||||
return doc;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[auditLog] failed to record audit entry',
|
||||
{ action: input.action, capability: input.capability, tenantId: input.tenantId },
|
||||
/**
|
||||
* Audit emission must never block a privileged operation, so a failed
|
||||
* write returns null instead of throwing. The structured payload below
|
||||
* is the only forensic trail when downstream alerting flags the
|
||||
* `failed to record audit entry` message.
|
||||
*/
|
||||
logger.error('[auditLog] failed to record audit entry', {
|
||||
action: input.action,
|
||||
capability: input.capability,
|
||||
tenantId: input.tenantId,
|
||||
actorId:
|
||||
typeof input.actorId === 'string' ? input.actorId : (input.actorId?.toString?.() ?? null),
|
||||
targetPrincipalType: input.targetPrincipalType,
|
||||
targetPrincipalId:
|
||||
typeof input.targetPrincipalId === 'string'
|
||||
? input.targetPrincipalId
|
||||
: (input.targetPrincipalId?.toString?.() ?? null),
|
||||
err,
|
||||
);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +165,7 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
|
|||
async function findAuditLogEntry(
|
||||
tenantId: string | undefined,
|
||||
id: string,
|
||||
): Promise<AdminAuditLogEntryWire | null> {
|
||||
): Promise<AdminAuditLogEntry | null> {
|
||||
if (!/^[a-fA-F0-9]{24}$/.test(id)) return null;
|
||||
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
|
||||
const query: FilterQuery<IAuditLog> = { _id: id, ...tenantFilter(tenantId) };
|
||||
|
|
@ -201,19 +176,29 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
|
|||
async function streamAuditLogEntries(
|
||||
tenantId: string | undefined,
|
||||
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
|
||||
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
|
||||
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
|
||||
options?: { isCancelled?: () => boolean },
|
||||
): Promise<number> {
|
||||
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
|
||||
const query = buildFilter(tenantId, filters);
|
||||
const cursor = AuditLog.find(query)
|
||||
.sort({ createdAt: -1, _id: -1 })
|
||||
.lean<IAuditLog>()
|
||||
.lean<IAuditLog[]>()
|
||||
.cursor({ batchSize: 500 });
|
||||
|
||||
const isCancelled = options?.isCancelled;
|
||||
let count = 0;
|
||||
for await (const doc of cursor) {
|
||||
await onEntry(toWire(doc as IAuditLog));
|
||||
count++;
|
||||
try {
|
||||
for await (const doc of cursor) {
|
||||
if (isCancelled?.()) {
|
||||
await cursor.close();
|
||||
break;
|
||||
}
|
||||
await onEntry(toWire(doc));
|
||||
count++;
|
||||
}
|
||||
} finally {
|
||||
await cursor.close().catch(() => undefined);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,14 +20,7 @@ import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
|
|||
import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
|
||||
import { createAclEntryMethods, permissionBitSupersets, type AclEntryMethods } from './aclEntry';
|
||||
import { createSystemGrantMethods, type SystemGrantMethods } from './systemGrant';
|
||||
import {
|
||||
createAuditLogMethods,
|
||||
type AuditLogMethods,
|
||||
type AuditLogFilters,
|
||||
type AuditLogPage,
|
||||
type AdminAuditLogEntryWire,
|
||||
type RecordAuditEntryInput,
|
||||
} from './auditLog';
|
||||
import { createAuditLogMethods, type AuditLogMethods } from './auditLog';
|
||||
import { createShareMethods, type ShareMethods } from './share';
|
||||
/* Tier 1 — Simple CRUD */
|
||||
import { createActionMethods, type ActionMethods } from './action';
|
||||
|
|
@ -295,10 +288,6 @@ export type {
|
|||
AclEntryMethods,
|
||||
SystemGrantMethods,
|
||||
AuditLogMethods,
|
||||
AuditLogFilters,
|
||||
AuditLogPage,
|
||||
AdminAuditLogEntryWire,
|
||||
RecordAuditEntryInput,
|
||||
ShareMethods,
|
||||
AccessRoleMethods,
|
||||
PluginAuthMethods,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Document, Types } from 'mongoose';
|
||||
import type { PrincipalType } from 'librechat-data-provider';
|
||||
import type { AuditAction } from './admin';
|
||||
import type { AdminAuditLogEntry, AuditAction } from './admin';
|
||||
|
||||
export type AuditLog = {
|
||||
action: AuditAction;
|
||||
|
|
@ -24,3 +24,34 @@ export type IAuditLog = AuditLog &
|
|||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface RecordAuditEntryInput {
|
||||
action: AuditAction;
|
||||
actorId: string | Types.ObjectId;
|
||||
actorName: string;
|
||||
targetPrincipalType: PrincipalType;
|
||||
targetPrincipalId: string | Types.ObjectId;
|
||||
targetName: string;
|
||||
capability: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
search?: string;
|
||||
action?: AuditAction[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
/** Case-insensitive substring match against the denormalized `actorName`. */
|
||||
actorQuery?: string;
|
||||
targetPrincipalType?: PrincipalType;
|
||||
/** Case-insensitive substring match against the denormalized `targetName`. */
|
||||
targetQuery?: string;
|
||||
capability?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogPage {
|
||||
entries: AdminAuditLogEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue