From 73ecf162fd5daad9146e0e897755d7df113b7701 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:06:01 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Address=20P1=20a?= =?UTF-8?q?udit-log=20review=20findings=20(cursor=20cancel,=20drain=20race?= =?UTF-8?q?,=20filter=20naming,=20type=20dedupe,=20tenant=20scope,=20log?= =?UTF-8?q?=20enrichment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` cast in `buildFilter` with a typed local. Switched the stream cursor to `lean()` and removed the `as IAuditLog` cast inside the loop. --- packages/api/src/admin/auditLog.ts | 170 +++++++++++++----- .../data-schemas/src/methods/auditLog.spec.ts | 47 ++++- packages/data-schemas/src/methods/auditLog.ts | 131 ++++++-------- packages/data-schemas/src/methods/index.ts | 13 +- packages/data-schemas/src/types/auditLog.ts | 33 +++- 5 files changed, 258 insertions(+), 136 deletions(-) diff --git a/packages/api/src/admin/auditLog.ts b/packages/api/src/admin/auditLog.ts index c48c80e31d..b7fdbfa580 100644 --- a/packages/api/src/admin/auditLog.ts +++ b/packages/api/src/admin/auditLog.ts @@ -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(['grant_assigned', 'grant_removed']); const VALID_PRINCIPAL_TYPES = new Set(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; findAuditLogEntry: ( tenantId: string | undefined, id: string, - ) => Promise; + ) => Promise; streamAuditLogEntries: ( tenantId: string | undefined, - filters: Parameters[1], - onEntry: (entry: AdminAuditLogEntryWire) => void | Promise, + filters: Omit, + onEntry: (entry: AdminAuditLogEntry) => void | Promise, + options?: { isCancelled?: () => boolean }, ) => Promise; } @@ -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 => { if (clientAborted) return Promise.resolve(); if (res.write(chunk)) return Promise.resolve(); - return new Promise((resolve) => res.once('drain', () => resolve())); + return new Promise((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); diff --git a/packages/data-schemas/src/methods/auditLog.spec.ts b/packages/data-schemas/src/methods/auditLog.spec.ts index 67d1dc03af..07f8a4310a 100644 --- a/packages/data-schemas/src/methods/auditLog.spec.ts +++ b/packages/data-schemas/src/methods/auditLog.spec.ts @@ -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); + }); }); }); diff --git a/packages/data-schemas/src/methods/auditLog.ts b/packages/data-schemas/src/methods/auditLog.ts index b92eb4ec8a..6adafb1e84 100644 --- a/packages/data-schemas/src/methods/auditLog.ts +++ b/packages/data-schemas/src/methods/auditLog.ts @@ -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; listAuditLogPage: ( @@ -61,11 +21,12 @@ export interface AuditLogMethods { findAuditLogEntry: ( tenantId: string | undefined, id: string, - ) => Promise; + ) => Promise; streamAuditLogEntries: ( tenantId: string | undefined, filters: Omit, - onEntry: (entry: AdminAuditLogEntryWire) => void | Promise, + onEntry: (entry: AdminAuditLogEntry) => void | Promise, + options?: { isCancelled?: () => boolean }, ) => Promise; } @@ -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 { - 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).$gte = filters.from; - if (filters.to) (query.createdAt as Record).$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 { + ): Promise { if (!/^[a-fA-F0-9]{24}$/.test(id)) return null; const AuditLog = mongoose.models.AuditLog as Model; const query: FilterQuery = { _id: id, ...tenantFilter(tenantId) }; @@ -201,19 +176,29 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi async function streamAuditLogEntries( tenantId: string | undefined, filters: Omit, - onEntry: (entry: AdminAuditLogEntryWire) => void | Promise, + onEntry: (entry: AdminAuditLogEntry) => void | Promise, + options?: { isCancelled?: () => boolean }, ): Promise { const AuditLog = mongoose.models.AuditLog as Model; const query = buildFilter(tenantId, filters); const cursor = AuditLog.find(query) .sort({ createdAt: -1, _id: -1 }) - .lean() + .lean() .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; } diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 3b73c100fe..8d3256dbc5 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -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, diff --git a/packages/data-schemas/src/types/auditLog.ts b/packages/data-schemas/src/types/auditLog.ts index 836a5f98f9..cd06b79911 100644 --- a/packages/data-schemas/src/types/auditLog.ts +++ b/packages/data-schemas/src/types/auditLog.ts @@ -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; +}