diff --git a/api/server/index.js b/api/server/index.js index 501946a413..a8c7c35e5a 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -243,6 +243,7 @@ const startServer = async () => { app.use('/api/admin/roles', routes.adminRoles); app.use('/api/admin/skills', routes.adminSkills); app.use('/api/admin/users', routes.adminUsers); + app.use('/api/admin/audit-log', routes.adminAuditLog); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); diff --git a/api/server/routes/admin/audit.js b/api/server/routes/admin/audit.js new file mode 100644 index 0000000000..083e905fe3 --- /dev/null +++ b/api/server/routes/admin/audit.js @@ -0,0 +1,25 @@ +const express = require('express'); +const { createAdminAuditLogHandlers } = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { requireCapability } = require('~/server/middleware/roles/capabilities'); +const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); + +const router = express.Router(); + +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); + +const handlers = createAdminAuditLogHandlers({ + recordAuditEntry: db.recordAuditEntry, + listAuditLogPage: db.listAuditLogPage, + findAuditLogEntry: db.findAuditLogEntry, + streamAuditLogEntries: db.streamAuditLogEntries, +}); + +router.use(requireJwtAuth, requireAdminAccess); + +router.get('/', handlers.listAuditLog); +router.get('/export.csv', handlers.exportAuditLogCsv); +router.get('/:id', handlers.getAuditLogEntry); + +module.exports = router; diff --git a/api/server/routes/admin/grants.js b/api/server/routes/admin/grants.js index a0fa73dc43..9aa01e036f 100644 --- a/api/server/routes/admin/grants.js +++ b/api/server/routes/admin/grants.js @@ -21,6 +21,15 @@ const handlers = createAdminGrantsHandlers({ getHeldCapabilities: db.getHeldCapabilities, getCachedPrincipals, checkRoleExists: async (name) => (await db.getRoleByName(name)) != null, + recordAuditEntry: db.recordAuditEntry, + resolveUserName: async (userId) => { + try { + const user = await db.getUserById(userId, 'name username email'); + return user?.name || user?.username || user?.email || null; + } catch { + return null; + } + }, }); router.use(requireJwtAuth, requireAdminAccess); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 59955e937e..ac2b38f579 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -8,6 +8,7 @@ const adminGroups = require('./admin/groups'); const adminRoles = require('./admin/roles'); const adminSkills = require('./admin/skills'); const adminUsers = require('./admin/users'); +const adminAuditLog = require('./admin/audit'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); @@ -47,6 +48,7 @@ module.exports = { adminRoles, adminSkills, adminUsers, + adminAuditLog, keys, apiKeys, user, diff --git a/packages/api/src/admin/auditLog.ts b/packages/api/src/admin/auditLog.ts new file mode 100644 index 0000000000..94e12d7c4a --- /dev/null +++ b/packages/api/src/admin/auditLog.ts @@ -0,0 +1,269 @@ +import { PrincipalType } from 'librechat-data-provider'; +import { logger } from '@librechat/data-schemas'; +import type { + AdminAuditLogEntryWire, + AuditAction, + AuditLogPage, + 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]/; +const CSV_BOM = ''; + +const VALID_ACTIONS = new Set(['grant_assigned', 'grant_removed']); +const VALID_PRINCIPAL_TYPES = new Set(Object.values(PrincipalType)); + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?Z?)?$/; +const OBJECT_ID_RE = /^[a-fA-F0-9]{24}$/; + +export interface AdminAuditLogDeps { + recordAuditEntry: (input: RecordAuditEntryInput) => Promise; + listAuditLogPage: ( + tenantId: string | undefined, + filters: { + search?: string; + action?: AuditAction[]; + from?: Date; + to?: Date; + actorId?: string; + targetPrincipalType?: PrincipalType; + targetPrincipalId?: string; + capability?: string; + cursor?: string; + limit?: number; + }, + ) => Promise; + findAuditLogEntry: ( + tenantId: string | undefined, + id: string, + ) => Promise; + streamAuditLogEntries: ( + tenantId: string | undefined, + filters: Parameters[1], + onEntry: (entry: AdminAuditLogEntryWire) => void | Promise, + ) => Promise; +} + +interface CallerContext { + userId: string; + role: string; + tenantId?: string; +} + +function resolveCaller(req: ServerRequest): CallerContext | null { + const user = req.user; + if (!user) return null; + const userId = user._id?.toString() ?? user.id; + if (!userId || !user.role) return null; + return { userId, role: user.role, tenantId: user.tenantId }; +} + +function asStringArray(v: unknown): string[] | undefined { + if (v == null) return undefined; + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string'); + if (typeof v === 'string') return [v]; + return undefined; +} + +function parseActionFilter(raw: unknown): AuditAction[] | undefined { + 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; +} + +function parseIsoDate(raw: unknown): { ok: true; value?: Date } | { ok: false; error: string } { + if (raw == null || raw === '') return { ok: true, value: undefined }; + if (typeof raw !== 'string') return { ok: false, error: 'Date must be a string' }; + if (!ISO_DATE_RE.test(raw)) return { ok: false, error: 'Date must be ISO 8601' }; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return { ok: false, error: 'Invalid date' }; + return { ok: true, value: d }; +} + +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' }; + if (n < 1) return { ok: false, error: 'limit must be >= 1' }; + if (n > 500) return { ok: false, error: 'limit must be <= 500' }; + 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 pickString(raw: unknown, maxLen = 256): string | undefined { + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, maxLen); +} + +function escapeCsvCell(value: string): string { + if (value === '') return ''; + const guarded = FORMULA_PREFIX.test(value) ? `'${value}` : value; + if (/[",\n\r]/.test(guarded)) { + return `"${guarded.replace(/"/g, '""')}"`; + } + return guarded; +} + +const CSV_COLUMNS: ReadonlyArray<{ key: keyof AdminAuditLogEntryWire; label: string }> = [ + { key: 'timestamp', label: 'Timestamp' }, + { key: 'action', label: 'Action' }, + { key: 'actorName', label: 'Actor' }, + { key: 'actorId', label: 'Actor ID' }, + { key: 'targetPrincipalType', label: 'Target type' }, + { key: 'targetPrincipalId', label: 'Target ID' }, + { key: 'targetName', label: 'Target' }, + { key: 'capability', label: 'Capability' }, +]; + +function formatCsvHeader(): string { + return CSV_COLUMNS.map((c) => escapeCsvCell(c.label)).join(','); +} + +function formatCsvRow(entry: AdminAuditLogEntryWire): string { + return CSV_COLUMNS.map((c) => escapeCsvCell(String(entry[c.key] ?? ''))).join(','); +} + +interface ParsedFilters { + search?: string; + action?: AuditAction[]; + from?: Date; + to?: Date; + actorId?: string; + targetPrincipalType?: PrincipalType; + targetPrincipalId?: string; + capability?: string; +} + +function parseFilters( + query: Record, +): { ok: true; value: ParsedFilters } | { ok: false; error: string } { + const from = parseIsoDate(query.from); + 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}` }; + return { + ok: true, + value: { + search: pickString(query.search, 200), + action: parseActionFilter(query.action), + from: from.value, + to: to.value, + actorId: pickString(query.actorId, 128), + targetPrincipalType: parsePrincipalType(query.targetPrincipalType), + targetPrincipalId: pickString(query.targetPrincipalId, 128), + capability: pickString(query.capability, 256), + }, + }; +} + +export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps) { + const { listAuditLogPage, findAuditLogEntry, streamAuditLogEntries } = deps; + + async function listAuditLogHandler(req: ServerRequest, res: Response) { + try { + const caller = resolveCaller(req); + if (!caller) return res.status(401).json({ error: 'Authentication required' }); + + const filters = parseFilters(req.query as Record); + if (!filters.ok) return res.status(400).json({ error: filters.error }); + + const cursor = pickString((req.query as Record).cursor, 256); + const limitResult = parseLimit((req.query as Record).limit); + if (!limitResult.ok) return res.status(400).json({ error: limitResult.error }); + + const page = await listAuditLogPage(caller.tenantId, { + ...filters.value, + cursor, + limit: limitResult.value, + }); + + return res.status(200).json(page); + } catch (err) { + logger.error('[adminAuditLog] list error:', err); + return res.status(500).json({ error: 'Failed to fetch audit log' }); + } + } + + async function getAuditLogEntryHandler(req: ServerRequest, res: Response) { + try { + const caller = resolveCaller(req); + if (!caller) return res.status(401).json({ error: 'Authentication required' }); + + const { id } = req.params as { id?: string }; + if (!id || !OBJECT_ID_RE.test(id)) { + return res.status(400).json({ error: 'Invalid id' }); + } + + const entry = await findAuditLogEntry(caller.tenantId, id); + if (!entry) return res.status(404).json({ error: 'Not found' }); + return res.status(200).json({ entry }); + } catch (err) { + logger.error('[adminAuditLog] get error:', err); + return res.status(500).json({ error: 'Failed to fetch audit log entry' }); + } + } + + async function exportAuditLogCsvHandler(req: ServerRequest, res: Response) { + try { + const caller = resolveCaller(req); + if (!caller) return res.status(401).json({ error: 'Authentication required' }); + + const filters = parseFilters(req.query as Record); + 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('Cache-Control', 'no-store'); + + res.write(CSV_BOM); + res.write(formatCsvHeader()); + res.write('\r\n'); + + await streamAuditLogEntries(caller.tenantId, filters.value, (entry) => { + res.write(formatCsvRow(entry)); + res.write('\r\n'); + }); + + res.end(); + } catch (err) { + logger.error('[adminAuditLog] export error:', err); + if (!res.headersSent) { + return res.status(500).json({ error: 'Failed to export audit log' }); + } + res.end(); + } + } + + return { + listAuditLog: listAuditLogHandler, + getAuditLogEntry: getAuditLogEntryHandler, + 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; +}; diff --git a/packages/api/src/admin/grants.ts b/packages/api/src/admin/grants.ts index f5d42ce071..f9e3ef5d4d 100644 --- a/packages/api/src/admin/grants.ts +++ b/packages/api/src/admin/grants.ts @@ -72,6 +72,19 @@ export interface AdminGrantsDeps { tenantId?: string; }) => ResolvedPrincipal[] | undefined; checkRoleExists?: (roleId: string) => Promise; + /** Optional audit emission. Failure is logged but does not roll back the grant. */ + recordAuditEntry?: (input: { + action: 'grant_assigned' | 'grant_removed'; + actorId: string | Types.ObjectId; + actorName: string; + targetPrincipalType: PrincipalType; + targetPrincipalId: string | Types.ObjectId; + targetName: string; + capability: string; + tenantId?: string; + }) => Promise; + /** Resolves a User document's display name from its id (for audit actor name). */ + resolveUserName?: (userId: string | Types.ObjectId) => Promise; } /** Currently ROLE-only; Record/Set structure preserved for future principal-type expansion. */ @@ -97,8 +110,39 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): { getHeldCapabilities, getCachedPrincipals, checkRoleExists, + recordAuditEntry, + resolveUserName, } = deps; + async function emitAudit(args: { + action: 'grant_assigned' | 'grant_removed'; + caller: { userId: string; role: string; tenantId?: string }; + principalType: PrincipalType; + principalId: string; + capability: SystemCapability; + }): Promise { + if (!recordAuditEntry) return; + try { + 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; + await recordAuditEntry({ + action: args.action, + actorId: args.caller.userId, + actorName, + targetPrincipalType: args.principalType, + targetPrincipalId: args.principalId, + targetName, + capability: args.capability, + tenantId: args.caller.tenantId, + }); + } catch (err) { + // Audit failure must not roll back the grant — log and move on. + logger.error('[adminGrants] audit write failed', err); + } + } + const MANAGE_CAPABILITY_BY_TYPE: Record = { [PrincipalType.ROLE]: SystemCapabilities.MANAGE_ROLES, }; @@ -352,6 +396,13 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): { if (!grant) { return res.status(500).json({ error: 'Grant operation returned no result' }); } + await emitAudit({ + action: 'grant_assigned', + caller, + principalType, + principalId, + capability, + }); return res.status(201).json({ grant }); } catch (error) { logger.error('[adminGrants] assignGrant error:', error); @@ -398,6 +449,13 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): { capability: capability as SystemCapability, tenantId, }); + await emitAudit({ + action: 'grant_removed', + caller, + principalType: principalType as PrincipalType, + principalId, + capability: capability as SystemCapability, + }); return res.status(200).json({ success: true }); } catch (error) { logger.error('[adminGrants] revokeGrant error:', error); diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index 90f826d7bf..52f0d68e71 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -4,9 +4,11 @@ export { createAdminGroupsHandlers } from './groups'; export { createAdminRolesHandlers } from './roles'; export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills'; export { createAdminUsersHandlers } from './users'; +export { createAdminAuditLogHandlers } from './auditLog'; export type { AdminConfigDeps } from './config'; export type { AdminGrantsDeps, GrantPrincipalType } from './grants'; export type { AdminGroupsDeps } from './groups'; export type { AdminRolesDeps } from './roles'; export type { AdminSkillSyncAccessDeps, AdminSkillSyncDeps } from './skills'; export type { AdminUsersDeps } from './users'; +export type { AdminAuditLogDeps } from './auditLog'; diff --git a/packages/data-schemas/src/methods/auditLog.ts b/packages/data-schemas/src/methods/auditLog.ts new file mode 100644 index 0000000000..f36bc6a87d --- /dev/null +++ b/packages/data-schemas/src/methods/auditLog.ts @@ -0,0 +1,245 @@ +import { PrincipalType } from 'librechat-data-provider'; +import type { FilterQuery, Model, Types } from 'mongoose'; +import type { AuditAction, IAuditLog } from '~/types'; +import logger from '~/config/winston'; + +const DEFAULT_LIMIT = 100; +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; + cursor?: string; + limit?: number; +} + +export interface AuditLogPage { + entries: AdminAuditLogEntryWire[]; + nextCursor: string | null; +} + +/** + * 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: ( + tenantId: string | undefined, + filters: AuditLogFilters, + ) => Promise; + findAuditLogEntry: ( + tenantId: string | undefined, + id: string, + ) => Promise; + streamAuditLogEntries: ( + tenantId: string | undefined, + filters: Omit, + onEntry: (entry: AdminAuditLogEntryWire) => void | Promise, + ) => Promise; +} + +function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function toWire(doc: IAuditLog): AdminAuditLogEntryWire { + return { + id: doc._id.toString(), + action: doc.action, + actorId: doc.actorId.toString(), + actorName: doc.actorName, + targetPrincipalType: doc.targetPrincipalType, + targetPrincipalId: doc.targetPrincipalId.toString(), + targetName: doc.targetName, + capability: doc.capability, + timestamp: doc.createdAt.toISOString(), + }; +} + +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 { + return tenantId != null ? { tenantId } : { tenantId: { $exists: false } }; +} + +function buildFilter( + tenantId: string | undefined, + filters: AuditLogFilters, +): FilterQuery { + const query: FilterQuery = { ...tenantFilter(tenantId) }; + + if (filters.action && filters.action.length > 0) { + query.action = filters.action.length === 1 ? filters.action[0] : { $in: filters.action }; + } + if (filters.actorId) { + query.actorId = filters.actorId; + } + if (filters.targetPrincipalType) { + query.targetPrincipalType = filters.targetPrincipalType; + } + if (filters.targetPrincipalId) { + query.targetPrincipalId = filters.targetPrincipalId; + } + if (filters.capability) { + query.capability = filters.capability; + } + 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; + } + if (filters.search && filters.search.length > 0) { + const trimmed = filters.search.slice(0, MAX_SEARCH_LENGTH); + const safe = escapeRegex(trimmed); + query.$or = [ + { actorName: { $regex: safe, $options: 'i' } }, + { targetName: { $regex: safe, $options: 'i' } }, + { capability: { $regex: safe, $options: 'i' } }, + ]; + } + + return query; +} + +function clampLimit(limit?: number): number { + if (!limit || Number.isNaN(limit) || limit < 1) return DEFAULT_LIMIT; + return Math.min(Math.floor(limit), MAX_LIMIT); +} + +export function createAuditLogMethods(mongoose: typeof import('mongoose')): AuditLogMethods { + async function recordAuditEntry(input: RecordAuditEntryInput): Promise { + const AuditLog = mongoose.models.AuditLog as Model; + try { + const doc = await AuditLog.create({ + action: input.action, + actorId: input.actorId, + actorName: input.actorName, + targetPrincipalType: input.targetPrincipalType, + targetPrincipalId: input.targetPrincipalId, + targetName: input.targetName, + capability: input.capability, + ...(input.tenantId != null && { tenantId: input.tenantId }), + }); + return doc; + } catch (err) { + logger.error( + '[auditLog] failed to record audit entry', + { action: input.action, capability: input.capability, tenantId: input.tenantId }, + err, + ); + return null; + } + } + + async function listAuditLogPage( + tenantId: string | undefined, + filters: AuditLogFilters, + ): Promise { + const AuditLog = mongoose.models.AuditLog as Model; + const limit = clampLimit(filters.limit); + 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(); + + 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; + + return { + entries: page.map(toWire), + nextCursor, + }; + } + + async function findAuditLogEntry( + tenantId: string | undefined, + id: string, + ): 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) }; + const doc = await AuditLog.findOne(query).lean(); + return doc ? toWire(doc) : null; + } + + async function streamAuditLogEntries( + tenantId: string | undefined, + filters: Omit, + onEntry: (entry: AdminAuditLogEntryWire) => void | Promise, + ): Promise { + const AuditLog = mongoose.models.AuditLog as Model; + const query = buildFilter(tenantId, filters); + const cursor = AuditLog.find(query) + .sort({ createdAt: -1, _id: -1 }) + .lean() + .cursor({ batchSize: 500 }); + + let count = 0; + for await (const doc of cursor) { + await onEntry(toWire(doc as IAuditLog)); + count++; + } + return count; + } + + return { + recordAuditEntry, + listAuditLogPage, + findAuditLogEntry, + streamAuditLogEntries, + }; +} diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 9ea9e5f1e5..3b73c100fe 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -20,6 +20,14 @@ 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 { createShareMethods, type ShareMethods } from './share'; /* Tier 1 — Simple CRUD */ import { createActionMethods, type ActionMethods } from './action'; @@ -115,6 +123,7 @@ export type AllMethods = UserMethods & UserGroupMethods & AclEntryMethods & SystemGrantMethods & + AuditLogMethods & ShareMethods & AccessRoleMethods & PluginAuthMethods & @@ -242,6 +251,7 @@ export function createMethods( ...createUserGroupMethods(mongoose), ...aclEntryMethods, ...systemGrantMethods, + ...createAuditLogMethods(mongoose), ...createShareMethods(mongoose), ...createPluginAuthMethods(mongoose), /* Tier 1 */ @@ -284,6 +294,11 @@ export type { UserGroupMethods, AclEntryMethods, SystemGrantMethods, + AuditLogMethods, + AuditLogFilters, + AuditLogPage, + AdminAuditLogEntryWire, + RecordAuditEntryInput, ShareMethods, AccessRoleMethods, PluginAuthMethods, diff --git a/packages/data-schemas/src/models/auditLog.ts b/packages/data-schemas/src/models/auditLog.ts new file mode 100644 index 0000000000..c078fac632 --- /dev/null +++ b/packages/data-schemas/src/models/auditLog.ts @@ -0,0 +1,14 @@ +import type * as t from '~/types'; +import auditLogSchema from '~/schema/auditLog'; + +/** + * AuditLog is an append-only compliance record of SystemGrant changes. + * + * Like SystemGrant, the tenant-isolation plugin is intentionally not applied: + * the audit listing/export queries explicitly compose the tenantId filter + * from the JWT-resolved caller, and platform-level entries (admin operating + * outside any tenant context) use `{ tenantId: { $exists: false } }`. + */ +export function createAuditLogModel(mongoose: typeof import('mongoose')) { + return mongoose.models.AuditLog || mongoose.model('AuditLog', auditLogSchema); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 476091ec9c..69140db4fa 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -24,6 +24,7 @@ import { createBannerModel } from './banner'; import { createPresetModel } from './preset'; import { createPromptModel } from './prompt'; import { createMemoryModel } from './memory'; +import { createAuditLogModel } from './auditLog'; import { createConfigModel } from './config'; import { createTokenModel } from './token'; import { createAgentModel } from './agent'; @@ -71,6 +72,7 @@ export function createModels(mongoose: typeof import('mongoose')): { AccessRole: ReturnType; AclEntry: ReturnType; SystemGrant: ReturnType; + AuditLog: ReturnType; Group: ReturnType; Config: ReturnType; } { @@ -108,6 +110,7 @@ export function createModels(mongoose: typeof import('mongoose')): { AccessRole: createAccessRoleModel(mongoose), AclEntry: createAclEntryModel(mongoose), SystemGrant: createSystemGrantModel(mongoose), + AuditLog: createAuditLogModel(mongoose), Group: createGroupModel(mongoose), Config: createConfigModel(mongoose), }; diff --git a/packages/data-schemas/src/schema/auditLog.ts b/packages/data-schemas/src/schema/auditLog.ts new file mode 100644 index 0000000000..ed4833100f --- /dev/null +++ b/packages/data-schemas/src/schema/auditLog.ts @@ -0,0 +1,68 @@ +import { Schema } from 'mongoose'; +import { PrincipalType } from 'librechat-data-provider'; +import type { IAuditLog } from '~/types'; + +const auditLogSchema = new Schema( + { + action: { + type: String, + enum: ['grant_assigned', 'grant_removed'], + required: true, + }, + actorId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + actorName: { + type: String, + required: true, + }, + targetPrincipalType: { + type: String, + enum: Object.values(PrincipalType), + required: true, + }, + /** + * Mixed: ObjectId for USER/GROUP principals, role-name string for ROLE. + * Normalization (string → ObjectId for USER/GROUP) is the responsibility + * of the writer (HTTP handler), mirroring the SystemGrant convention. + */ + targetPrincipalId: { + type: Schema.Types.Mixed, + required: true, + }, + targetName: { + type: String, + required: true, + }, + capability: { + type: String, + required: true, + }, + /** + * Platform-level audit entries MUST omit this field entirely — never set + * it to null. Queries for platform-level entries use + * `{ tenantId: { $exists: false } }`, which matches absent fields but NOT + * `null`. A document stored with `{ tenantId: null }` would silently match + * neither platform-level nor tenant-scoped queries. + */ + tenantId: { + type: String, + required: false, + validate: { + validator: (v: unknown) => v !== null && v !== '', + message: 'tenantId must be a non-empty string or omitted entirely — never null or empty', + }, + }, + }, + { timestamps: true }, +); + +/** Primary listing: tenant + newest-first + tiebreak on _id for keyset pagination. */ +auditLogSchema.index({ tenantId: 1, createdAt: -1, _id: -1 }); + +/** Target facet: filter by who was affected. */ +auditLogSchema.index({ tenantId: 1, targetPrincipalType: 1, targetPrincipalId: 1, createdAt: -1 }); + +export default auditLogSchema; diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 0dfd4dcb30..08471bf5c8 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -28,4 +28,5 @@ export { default as userSchema } from './user'; export { default as memorySchema } from './memory'; export { default as groupSchema } from './group'; export { default as systemGrantSchema } from './systemGrant'; +export { default as auditLogSchema } from './auditLog'; export { default as configSchema } from './config'; diff --git a/packages/data-schemas/src/types/auditLog.ts b/packages/data-schemas/src/types/auditLog.ts new file mode 100644 index 0000000000..f9dd172d46 --- /dev/null +++ b/packages/data-schemas/src/types/auditLog.ts @@ -0,0 +1,27 @@ +import type { Document, Types } from 'mongoose'; +import type { PrincipalType } from 'librechat-data-provider'; + +export type AuditAction = 'grant_assigned' | 'grant_removed'; + +export type AuditLog = { + action: AuditAction; + actorId: Types.ObjectId; + actorName: string; + targetPrincipalType: PrincipalType; + /** ObjectId for USER/GROUP, role name string for ROLE */ + targetPrincipalId: string | Types.ObjectId; + targetName: string; + capability: string; + /** Absent = platform-operator audit; present = tenant-scoped audit */ + tenantId?: string; + /** Mongoose auto-managed via { timestamps: true } */ + createdAt?: Date; + updatedAt?: Date; +}; + +export type IAuditLog = AuditLog & + Document & { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; + }; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index da4f42c435..3dc5228532 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -31,6 +31,7 @@ export * from './skillSync'; export * from './accessRole'; export * from './aclEntry'; export * from './systemGrant'; +export * from './auditLog'; export * from './group'; /* Config */ export * from './config';