mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
🛡️ 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:
parent
d8474864e9
commit
c3160cee5f
15 changed files with 740 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
25
api/server/routes/admin/audit.js
Normal file
25
api/server/routes/admin/audit.js
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
269
packages/api/src/admin/auditLog.ts
Normal file
269
packages/api/src/admin/auditLog.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
245
packages/data-schemas/src/methods/auditLog.ts
Normal file
245
packages/data-schemas/src/methods/auditLog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
14
packages/data-schemas/src/models/auditLog.ts
Normal file
14
packages/data-schemas/src/models/auditLog.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
68
packages/data-schemas/src/schema/auditLog.ts
Normal file
68
packages/data-schemas/src/schema/auditLog.ts
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
27
packages/data-schemas/src/types/auditLog.ts
Normal file
27
packages/data-schemas/src/types/auditLog.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue