🛠️ 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:
Dustin Healy 2026-05-14 10:06:01 -07:00 • committed by Danny Avila
parent bed3bc95b6
commit 73ecf162fd
5 changed files with 258 additions and 136 deletions

View file

@ -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);

View file

@ -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);
});
});
});

View file

@ -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;
}

View file

@ -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,

View file

@ -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;
}