🛡️ feat: Audit log backend for SystemGrants changes

Add an AuditLog Mongoose collection that records every grant assign/revoke as an append-only entry capturing the actor, target principal, capability, timestamp, and tenant scope. Wire the entry-write into the existing admin assignGrant and revokeGrant handlers so the admin panel's audit-log tab populates as grants happen.

The data-schemas package gains the IAuditLog type, a Mongoose schema with tenant + target compound indexes for keyset pagination, a model factory wired through createModels, and an AuditLog methods factory exposing recordAuditEntry, listAuditLogPage (cursor-paginated, faceted, search-aware), findAuditLogEntry, and streamAuditLogEntries.

The packages/api admin layer adds createAdminAuditLogHandlers with three handlers backing the routes the admin panel already consumes: GET /api/admin/audit-log returns paginated entries, GET /api/admin/audit-log/:id returns a single entry for the permalink drawer, and GET /api/admin/audit-log/export.csv streams CSV with formula-injection defang plus UTF-8 BOM.

The Express layer mounts the new router at /api/admin/audit-log behind requireJwtAuth and the ACCESS_ADMIN capability, matching the existing admin route pattern. The audit emission failure is logged via logger.error but never rolls back the grant.
This commit is contained in:
Dustin Healy 2026-05-11 17:24:44 -07:00 committed by Danny Avila
parent d8474864e9
commit c3160cee5f
15 changed files with 740 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AuditAction>(['grant_assigned', 'grant_removed']);
const VALID_PRINCIPAL_TYPES = new Set<string>(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<unknown>;
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<AuditLogPage>;
findAuditLogEntry: (
tenantId: string | undefined,
id: string,
) => Promise<AdminAuditLogEntryWire | null>;
streamAuditLogEntries: (
tenantId: string | undefined,
filters: Parameters<AdminAuditLogDeps['listAuditLogPage']>[1],
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
) => Promise<number>;
}
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<string, unknown>,
): { 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<string, unknown>);
if (!filters.ok) return res.status(400).json({ error: filters.error });
const cursor = pickString((req.query as Record<string, unknown>).cursor, 256);
const limitResult = parseLimit((req.query as Record<string, unknown>).limit);
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<string, unknown>);
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;
};

View file

@ -72,6 +72,19 @@ export interface AdminGrantsDeps {
tenantId?: string;
}) => ResolvedPrincipal[] | undefined;
checkRoleExists?: (roleId: string) => Promise<boolean>;
/** 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<unknown>;
/** Resolves a User document's display name from its id (for audit actor name). */
resolveUserName?: (userId: string | Types.ObjectId) => Promise<string | null>;
}
/** 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<void> {
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<GrantPrincipalType, SystemCapability> = {
[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);

View file

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

View file

@ -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<IAuditLog | null>;
listAuditLogPage: (
tenantId: string | undefined,
filters: AuditLogFilters,
) => Promise<AuditLogPage>;
findAuditLogEntry: (
tenantId: string | undefined,
id: string,
) => Promise<AdminAuditLogEntryWire | null>;
streamAuditLogEntries: (
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'cursor' | 'limit'>,
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
) => Promise<number>;
}
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<IAuditLog> {
return tenantId != null ? { tenantId } : { tenantId: { $exists: false } };
}
function buildFilter(
tenantId: string | undefined,
filters: AuditLogFilters,
): FilterQuery<IAuditLog> {
const query: FilterQuery<IAuditLog> = { ...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<string, Date>).$gte = filters.from;
if (filters.to) (query.createdAt as Record<string, Date>).$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<IAuditLog | null> {
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
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<AuditLogPage> {
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
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<IAuditLog[]>();
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const last = page[page.length - 1];
const nextCursor = hasMore && last ? encodeCursor(last._id) : null;
return {
entries: page.map(toWire),
nextCursor,
};
}
async function findAuditLogEntry(
tenantId: string | undefined,
id: string,
): Promise<AdminAuditLogEntryWire | 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) };
const doc = await AuditLog.findOne(query).lean<IAuditLog>();
return doc ? toWire(doc) : null;
}
async function streamAuditLogEntries(
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'cursor' | 'limit'>,
onEntry: (entry: AdminAuditLogEntryWire) => void | Promise<void>,
): 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>()
.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,
};
}

View file

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

View file

@ -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<t.IAuditLog>('AuditLog', auditLogSchema);
}

View file

@ -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<typeof createAccessRoleModel>;
AclEntry: ReturnType<typeof createAclEntryModel>;
SystemGrant: ReturnType<typeof createSystemGrantModel>;
AuditLog: ReturnType<typeof createAuditLogModel>;
Group: ReturnType<typeof createGroupModel>;
Config: ReturnType<typeof createConfigModel>;
} {
@ -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),
};

View file

@ -0,0 +1,68 @@
import { Schema } from 'mongoose';
import { PrincipalType } from 'librechat-data-provider';
import type { IAuditLog } from '~/types';
const auditLogSchema = new Schema<IAuditLog>(
{
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;

View file

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

View file

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

View file

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