🛡️ feat: Generalize audit log into a tamper-evident, extensible event substrate

Reworks the SystemGrant-only audit log into a general-purpose, append-only
compliance substrate designed to absorb future event classes (agent runs,
tool/MCP calls, config + permission changes, approvals) without reshaping the
record. Nothing was shipped yet, so this replaces the grant-specific wire
shape rather than layering aliases.

Schema / record shape (packages/data-schemas):
- schemaVersion + two-level taxonomy: category + namespaced action
  (grant.assigned/grant.removed), first-class outcome and severity.
- Structured actor{type,id,name} supporting non-user actors (system, agent,
  service, schedule, webhook, api); generic target{type,id,name}; open
  metadata map; request context{requestId,ip,userAgent,sessionId}.

Tamper-evidence (hash chain):
- Per-tenant chain keyed by chainKey with seq/prevHash/hash. Appends link to
  the previous hash; a unique {chainKey,seq} index serializes concurrent
  writes (dup-key retry) so the chain can never fork. createdAt is explicit so
  it's covered by the hash.
- verifyAuditChain() walks a chain and detects modification, deletion, and
  forged links; exposed via GET /api/admin/audit-log/verify.

Other best-practice gaps from the review:
- Keyset (cursor) pagination over seq alongside offset; stable under
  concurrent appends. nextCursor in the page payload.
- Retention: purgeAuditLogEntries() privileged prefix-purge with a confirm
  latch, returns a checkpoint; verify tolerates a purged prefix.
- Fail-closed option (AUDIT_LOG_FAIL_CLOSED) so a failed audit write can fail
  the grant request instead of being swallowed; default stays fail-open.
- Grant handlers now capture request context and emit the new shape.

CSV export updated for the new columns (incl. seq/hash). data-schemas bumped
to 0.0.54 for the sibling admin-panel consumer. Tests rewritten: 28
methods-layer cases (chain genesis/linking, tamper detection, keyset, purge)
and the handler/grants specs updated for the new shape, fail-closed, and the
verify endpoint.
This commit is contained in:
Danny Avila 2026-06-18 09:39:24 -04:00
parent 1ca6877caa
commit bc8cef88ab
15 changed files with 1538 additions and 960 deletions

View file

@ -14,6 +14,7 @@ const handlers = createAdminAuditLogHandlers({
listAuditLogPage: db.listAuditLogPage,
findAuditLogEntry: db.findAuditLogEntry,
streamAuditLogEntries: db.streamAuditLogEntries,
verifyAuditChain: db.verifyAuditChain,
});
/**
@ -26,8 +27,9 @@ const handlers = createAdminAuditLogHandlers({
router.use(requireJwtAuth, requireAdminAccess, requireAuditLogRead);
router.get('/', handlers.listAuditLog);
/** `/export.csv` MUST precede `/:id` so it isn't matched as `{ id: 'export.csv' }`. */
/** Literal sub-paths MUST precede `/:id` so they aren't matched as `{ id }`. */
router.get('/export.csv', handlers.exportAuditLogCsv);
router.get('/verify', handlers.verifyAuditLog);
router.get('/:id', handlers.getAuditLogEntry);
module.exports = router;

View file

@ -22,6 +22,8 @@ const handlers = createAdminGrantsHandlers({
getCachedPrincipals,
checkRoleExists: async (name) => (await db.getRoleByName(name)) != null,
recordAuditEntry: db.recordAuditEntry,
/** Opt-in: fail the grant request if its audit entry can't be persisted. */
auditFailClosed: process.env.AUDIT_LOG_FAIL_CLOSED === 'true',
});
router.use(requireJwtAuth, requireAdminAccess);

View file

@ -1,7 +1,10 @@
import { Types } from 'mongoose';
import { EventEmitter } from 'events';
import { PrincipalType } from 'librechat-data-provider';
import type { AdminAuditLogEntry, AuditLogPage } from '@librechat/data-schemas';
import type {
AdminAuditLogEntry,
AuditChainVerification,
AuditLogPage,
} from '@librechat/data-schemas';
import type { Response } from 'express';
import type { AdminAuditLogDeps } from './auditLog';
import type { ServerRequest } from '~/types/http';
@ -17,18 +20,35 @@ const validObjectId = new Types.ObjectId().toString();
function mockEntry(overrides: Partial<AdminAuditLogEntry> = {}): AdminAuditLogEntry {
return {
id: new Types.ObjectId().toString(),
action: 'grant_assigned',
actorId: new Types.ObjectId().toString(),
actorName: 'Alice Admin',
targetPrincipalType: PrincipalType.USER,
targetPrincipalId: new Types.ObjectId().toString(),
targetName: 'Bob User',
capability: 'manage:users',
schemaVersion: 1,
category: 'grant',
action: 'grant.assigned',
outcome: 'success',
severity: 'warning',
actor: { type: 'user', id: new Types.ObjectId().toString(), name: 'Alice Admin' },
target: { type: 'role', id: 'ADMIN', name: 'ADMIN' },
metadata: { capability: 'manage:users' },
context: { ip: '10.0.0.1', requestId: 'req-1' },
integrity: { seq: 1, hash: 'a'.repeat(64), prevHash: '0'.repeat(64) },
timestamp: new Date('2025-01-15T10:30:00.000Z').toISOString(),
...overrides,
};
}
function mockPage(overrides: Partial<AuditLogPage> = {}): AuditLogPage {
return { entries: [], total: 0, nextCursor: null, ...overrides };
}
function mockVerification(overrides: Partial<AuditChainVerification> = {}): AuditChainVerification {
return {
ok: true,
chainKey: 'tenant-a',
checked: 3,
range: { firstSeq: 1, lastSeq: 3 },
...overrides,
};
}
function createReqRes(
overrides: {
params?: Record<string, string>;
@ -61,9 +81,8 @@ interface CsvCaptureContext {
}
/**
* Wraps the request/response in EventEmitter shims so the streaming
* handler can register `close` / `aborted` / `drain` listeners and the
* test can drive them.
* Wraps request/response in EventEmitter shims so the streaming handler can
* register `close` / `aborted` / `drain` listeners and the test can drive them.
*/
function createCsvContext(
overrides: {
@ -112,13 +131,14 @@ function createCsvContext(
function createDeps(overrides: Partial<AdminAuditLogDeps> = {}): AdminAuditLogDeps {
return {
listAuditLogPage: jest
.fn<Promise<AuditLogPage>, unknown[]>()
.mockResolvedValue({ entries: [], total: 0 }),
listAuditLogPage: jest.fn<Promise<AuditLogPage>, unknown[]>().mockResolvedValue(mockPage()),
findAuditLogEntry: jest
.fn<Promise<AdminAuditLogEntry | null>, unknown[]>()
.mockResolvedValue(null),
streamAuditLogEntries: jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0),
verifyAuditChain: jest
.fn<Promise<AuditChainVerification>, unknown[]>()
.mockResolvedValue(mockVerification()),
...overrides,
};
}
@ -126,61 +146,44 @@ function createDeps(overrides: Partial<AdminAuditLogDeps> = {}): AdminAuditLogDe
describe('createAdminAuditLogHandlers', () => {
describe('listAuditLog', () => {
it('returns 401 when req.user is missing', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status, json } = createReqRes({ user: undefined });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith({ error: 'Authentication required' });
});
it('returns 200 with the page payload', async () => {
const entries = [mockEntry()];
const deps = createDeps({
listAuditLogPage: jest.fn().mockResolvedValue({ entries, total: 1 }),
});
const page = mockPage({ entries: [mockEntry()], total: 1, nextCursor: 5 });
const deps = createDeps({ listAuditLogPage: jest.fn().mockResolvedValue(page) });
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes();
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ entries, total: 1 });
expect(json).toHaveBeenCalledWith(page);
});
it('rejects malformed `from` with 400', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { from: '2025-01-01 10:00' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/from/);
expect(deps.listAuditLogPage).not.toHaveBeenCalled();
});
it('rejects local-time ISO without a zone offset', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ query: { to: '2025-01-01T10:00:00' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
});
it('accepts ISO timestamps with a `+HH:MM` offset', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status } = createReqRes({
query: { from: '2025-01-01T10:00:00+02:00' },
});
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ query: { from: '2025-01-01T10:00:00+02:00' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(200);
});
@ -188,384 +191,226 @@ describe('createAdminAuditLogHandlers', () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({ query: { to: '2025-01-15' } });
await handlers.listAuditLog(req, res);
const filtersArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][1];
expect(filtersArg.to).toBeInstanceOf(Date);
expect((filtersArg.to as Date).toISOString()).toBe('2025-01-15T23:59:59.999Z');
});
it('leaves a full ISO `to` timestamp exact (no day-boundary widening)', async () => {
it('rejects an inverted date range with 400', async () => {
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({
query: { from: '2025-02-01', to: '2025-01-01' },
});
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
});
it.each([
['action', { action: 'nope' }],
['category', { category: 'nope' }],
['outcome', { outcome: 'nope' }],
['severity', { severity: 'nope' }],
['actorType', { actorType: 'nope' }],
])('rejects an unknown %s value with 400', async (_label, query) => {
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ query });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
});
it('passes a multi-value action filter through as an array', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({ query: { to: '2025-01-15T08:30:00Z' } });
const { req, res } = createReqRes({
query: { action: ['grant.assigned', 'grant.removed'] },
});
await handlers.listAuditLog(req, res);
const filtersArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][1];
expect((filtersArg.to as Date).toISOString()).toBe('2025-01-15T08:30:00.000Z');
expect(filtersArg.action).toEqual(['grant.assigned', 'grant.removed']);
});
it('rejects when `from` is later than `to`', async () => {
it('rejects limit over the cap, negative offset, and cursor < 1', async () => {
const handlers = createAdminAuditLogHandlers(createDeps());
const over = createReqRes({ query: { limit: '600' } });
await handlers.listAuditLog(over.req, over.res);
expect(over.status).toHaveBeenCalledWith(400);
const neg = createReqRes({ query: { offset: '-1' } });
await handlers.listAuditLog(neg.req, neg.res);
expect(neg.status).toHaveBeenCalledWith(400);
const badCursor = createReqRes({ query: { cursor: '0' } });
await handlers.listAuditLog(badCursor.req, badCursor.res);
expect(badCursor.status).toHaveBeenCalledWith(400);
});
it('passes limit/offset/cursor through to the data layer', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({
query: { from: '2025-12-31', to: '2025-01-01' },
});
const { req, res } = createReqRes({ query: { limit: '10', cursor: '42' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/from.*to/);
expect(deps.listAuditLogPage).not.toHaveBeenCalled();
const filtersArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][1];
expect(filtersArg.limit).toBe(10);
expect(filtersArg.cursor).toBe(42);
});
it('rejects limit > 500 with 400', async () => {
it('sources tenant scope from the JWT and ignores a forged ?tenantId', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { limit: '501' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/limit/);
});
it('rejects negative offset with 400', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { offset: '-1' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/offset/);
});
it('rejects an unknown `action` with 400', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { action: 'invalid_action' } });
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/action/i);
});
it('rejects an unknown `targetPrincipalType` with 400', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({
query: { targetPrincipalType: 'unknown' },
});
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/targetPrincipalType/);
});
it('uses caller.tenantId from req.user and ignores forged `tenantId` query', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({
query: { tenantId: 'attacker-tenant' } as Record<string, string>,
const req = {
params: {},
query: { tenantId: 'forged-tenant' },
body: {},
user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'real-tenant' },
});
} as unknown as ServerRequest;
const json = jest.fn();
const res = { status: jest.fn().mockReturnValue({ json }), json } as unknown as Response;
await handlers.listAuditLog(req, res);
expect(deps.listAuditLogPage).toHaveBeenCalledWith('real-tenant', expect.any(Object));
const tenantArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][0];
expect(tenantArg).toBe('real-tenant');
expect((deps.listAuditLogPage as jest.Mock).mock.calls[0][0]).toBe('real-tenant');
});
it('forwards `actorQuery` / `targetQuery` straight through', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({
query: { actorQuery: 'alice', targetQuery: 'bob' },
});
await handlers.listAuditLog(req, res);
expect(deps.listAuditLogPage).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ actorQuery: 'alice', targetQuery: 'bob' }),
);
});
it('maps deprecated `actorId` to `actorQuery` and `targetPrincipalId` to `targetQuery`', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({
query: { actorId: 'alice', targetPrincipalId: 'bob' },
});
await handlers.listAuditLog(req, res);
expect(deps.listAuditLogPage).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ actorQuery: 'alice', targetQuery: 'bob' }),
);
});
it('returns 500 when the dep throws', async () => {
it('returns 500 when the data layer throws', async () => {
const deps = createDeps({
listAuditLogPage: jest.fn().mockRejectedValue(new Error('db down')),
});
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes();
const { req, res, status } = createReqRes();
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Failed to fetch audit log' });
});
});
describe('getAuditLogEntry', () => {
it('returns 401 when req.user is missing', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({
params: { id: validObjectId },
user: undefined,
});
it('returns 401 when unauthenticated', async () => {
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ user: undefined, params: { id: validObjectId } });
await handlers.getAuditLogEntry(req, res);
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith({ error: 'Authentication required' });
});
it('returns 400 when id is not a 24-char hex ObjectId', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: 'not-an-objectid' } });
it('returns 400 for a non-ObjectId id', async () => {
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ params: { id: 'nope' } });
await handlers.getAuditLogEntry(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Invalid id' });
expect(deps.findAuditLogEntry).not.toHaveBeenCalled();
});
it('returns 404 when the dep returns null', async () => {
const deps = createDeps({
findAuditLogEntry: jest.fn().mockResolvedValue(null),
});
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validObjectId } });
it('returns 404 when not found', async () => {
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ params: { id: validObjectId } });
await handlers.getAuditLogEntry(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({ error: 'Not found' });
});
it('returns 200 with the entry payload', async () => {
it('returns 200 with the entry', async () => {
const entry = mockEntry();
const deps = createDeps({
findAuditLogEntry: jest.fn().mockResolvedValue(entry),
});
const deps = createDeps({ findAuditLogEntry: jest.fn().mockResolvedValue(entry) });
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validObjectId } });
await handlers.getAuditLogEntry(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ entry });
});
});
it('passes caller.tenantId, not a query param tenantId, to the dep', async () => {
const entry = mockEntry();
const deps = createDeps({
findAuditLogEntry: jest.fn().mockResolvedValue(entry),
});
describe('verifyAuditLog', () => {
it('returns 401 when unauthenticated', async () => {
const handlers = createAdminAuditLogHandlers(createDeps());
const { req, res, status } = createReqRes({ user: undefined });
await handlers.verifyAuditLog(req, res);
expect(status).toHaveBeenCalledWith(401);
});
it('returns the verification result scoped to the caller tenant', async () => {
const verification = mockVerification({ ok: false, brokenAt: 2, reason: 'hash mismatch' });
const deps = createDeps({ verifyAuditChain: jest.fn().mockResolvedValue(verification) });
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({
params: { id: validObjectId },
query: { tenantId: 'attacker' } as Record<string, string>,
const { req, res, status, json } = createReqRes({
user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'real-tenant' },
});
await handlers.verifyAuditLog(req, res);
expect((deps.verifyAuditChain as jest.Mock).mock.calls[0][0]).toBe('real-tenant');
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith(verification);
});
await handlers.getAuditLogEntry(req, res);
expect(deps.findAuditLogEntry).toHaveBeenCalledWith('real-tenant', validObjectId);
it('returns 500 when verification throws', async () => {
const deps = createDeps({
verifyAuditChain: jest.fn().mockRejectedValue(new Error('boom')),
});
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status } = createReqRes();
await handlers.verifyAuditLog(req, res);
expect(status).toHaveBeenCalledWith(500);
});
});
describe('exportAuditLogCsv', () => {
it('returns 401 when req.user is missing', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ user: undefined });
function streamFrom(entries: AdminAuditLogEntry[]): AdminAuditLogDeps['streamAuditLogEntries'] {
return jest.fn(async (_tenantId, _filters, onEntry, options) => {
let count = 0;
for (const entry of entries) {
if (options?.isCancelled?.()) break;
await onEntry(entry);
count++;
}
return count;
});
}
await handlers.exportAuditLogCsv(req, res);
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith({ error: 'Authentication required' });
it('returns 401 when unauthenticated', async () => {
const ctx = createCsvContext({ user: undefined });
const handlers = createAdminAuditLogHandlers(createDeps());
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
expect(ctx.res.status as jest.Mock).toHaveBeenCalledWith(401);
});
it('emits BOM as the first chunk and CRLF line endings', async () => {
const entry = mockEntry();
const deps = createDeps({
streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => {
await (onEntry as (e: AdminAuditLogEntry) => Promise<void>)(entry);
return 1;
}),
});
it('writes a BOM, header row, and CRLF line endings', async () => {
const deps = createDeps({ streamAuditLogEntries: streamFrom([mockEntry()]) });
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
expect(ctx.chunks[0]).toBe('\uFEFF');
expect(ctx.chunks).toContain('\r\n');
expect(ctx.chunks[0]).toBe('');
expect(ctx.chunks[1]).toContain('Timestamp');
expect(ctx.chunks[1]).toContain('Hash');
expect(ctx.chunks[2]).toBe('\r\n');
expect(ctx.endCalled()).toBe(true);
});
it('writes a header row matching the column labels', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
const header = ctx.chunks[1];
expect(header).toBe(
'Timestamp,Action,Actor,Actor ID,Target type,Target ID,Target,Capability',
);
});
it('escapes commas, quotes, and newlines inside cells', async () => {
it('defangs formula-injection cells and quotes special characters', async () => {
const entry = mockEntry({
actorName: 'Alice "Quoted", Admin',
targetName: 'multi\nline target',
});
const deps = createDeps({
streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => {
await (onEntry as (e: AdminAuditLogEntry) => Promise<void>)(entry);
return 1;
}),
actor: { type: 'user', id: 'x', name: '=cmd()' },
target: { type: 'role', id: 'r', name: 'Ops, North' },
});
const deps = createDeps({ streamAuditLogEntries: streamFrom([entry]) });
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
const rowChunk = ctx.chunks.find((c) => c.includes('Alice') && c.includes('Quoted'));
expect(rowChunk).toBeDefined();
expect(rowChunk).toContain('"Alice ""Quoted"", Admin"');
expect(rowChunk).toContain('"multi\nline target"');
const body = ctx.chunks.join('');
expect(body).toContain("'=cmd()");
expect(body).toContain('"Ops, North"');
});
it('defangs formula-injection prefixes (= + - @ tab CR) by quoting the cell', async () => {
const cases: string[] = ['=SUM(A1)', '+1+1', '-1+2', '@SUM(A1)', '\tTAB', '\rCR'];
for (const dangerous of cases) {
const entry = mockEntry({ actorName: dangerous });
const deps = createDeps({
streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => {
await (onEntry as (e: AdminAuditLogEntry) => Promise<void>)(entry);
return 1;
}),
});
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
const rowChunk = ctx.chunks.find(
(c) => c.includes("'") && c.includes(dangerous.replace(/[\r\n]/g, '')),
);
expect(rowChunk).toBeDefined();
const expectedGuarded = `'${dangerous}`;
expect(rowChunk).toContain(
expectedGuarded.includes(',') ? `"${expectedGuarded}"` : expectedGuarded,
);
}
});
it('passes isCancelled to the stream dep and flips to true when client aborts mid-stream', async () => {
let capturedIsCancelled: (() => boolean) | undefined;
const deps = createDeps({
streamAuditLogEntries: jest.fn(async (_t, _f, _onEntry, options) => {
capturedIsCancelled = (options as { isCancelled?: () => boolean })?.isCancelled;
expect(capturedIsCancelled?.()).toBe(false);
ctx.emitClose();
expect(capturedIsCancelled?.()).toBe(true);
return 0;
}),
});
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
expect(capturedIsCancelled).toBeDefined();
});
it('skips res.end when the client has already disconnected mid-stream', async () => {
const deps = createDeps({
streamAuditLogEntries: jest.fn(async (_t, _f, _onEntry, _options) => {
ctx.emitClose();
return 0;
}),
});
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
expect(ctx.endCalled()).toBe(false);
});
it('rejects malformed `from` with 400 before opening the stream', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { from: 'not-a-date' } });
await handlers.exportAuditLogCsv(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/from/);
expect(deps.streamAuditLogEntries).not.toHaveBeenCalled();
});
it('passes a maxRows cap into the stream call', async () => {
it('threads cancellation and a row cap into the stream', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
const optionsArg = (deps.streamAuditLogEntries as jest.Mock).mock.calls[0][3];
expect(optionsArg).toEqual(expect.objectContaining({ maxRows: expect.any(Number) }));
const promise = handlers.exportAuditLogCsv(ctx.req, ctx.res);
ctx.emitClose();
await promise;
const streamMock = deps.streamAuditLogEntries as jest.Mock;
const optionsArg = streamMock.mock.calls[0][3];
expect(optionsArg.isCancelled()).toBe(true);
expect(optionsArg.maxRows).toBeGreaterThan(0);
});
it('closes the response cleanly when the stream throws after headers are sent', async () => {
const entry = mockEntry();
const deps = createDeps({
streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => {
await (onEntry as (e: AdminAuditLogEntry) => Promise<void>)(entry);
throw new Error('cursor exploded mid-stream');
}),
});
it('returns 400 on malformed filters before streaming', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const ctx = createCsvContext();
/** Headers were already sent by the BOM / header write before the throw,
* so the catch block must use res.end() instead of trying to send JSON. */
(ctx.res as unknown as { headersSent: boolean }).headersSent = true;
const ctx = createCsvContext({ query: { from: 'garbage' } });
await handlers.exportAuditLogCsv(ctx.req, ctx.res);
expect(ctx.endCalled()).toBe(true);
const statusMock = (ctx.res as unknown as { status: jest.Mock }).status;
expect(statusMock).not.toHaveBeenCalled();
expect(ctx.res.status as jest.Mock).toHaveBeenCalledWith(400);
expect(deps.streamAuditLogEntries).not.toHaveBeenCalled();
});
});
});

View file

@ -1,35 +1,47 @@
import { PrincipalType } from 'librechat-data-provider';
import {
AUDIT_ACTIONS,
logger,
AUDIT_ACTIONS,
AUDIT_CATEGORIES,
AUDIT_OUTCOMES,
AUDIT_SEVERITIES,
AUDIT_ACTOR_TYPES,
MAX_AUDIT_EXPORT_ROWS,
MAX_AUDIT_LOG_LIMIT,
} from '@librechat/data-schemas';
import type {
AdminAuditLogEntry,
AuditAction,
AuditActorType,
AuditCategory,
AuditChainVerification,
AuditLogFilters,
AuditLogPage,
AuditOutcome,
AuditSeverity,
} from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
/** UTF-8 BOM, written first so Excel recognizes the encoding on import. Spelled
* as a Unicode escape so readers can see the constant in editors that hide
* the zero-width glyph. */
const CSV_BOM = '\uFEFF';
* as a Unicode escape so readers can see the constant in editors that hide the
* zero-width glyph. */
const CSV_BOM = '';
const VALID_ACTIONS = new Set<AuditAction>(AUDIT_ACTIONS);
const VALID_PRINCIPAL_TYPES = new Set<string>(Object.values(PrincipalType));
const VALID_ACTIONS = new Set<string>(AUDIT_ACTIONS);
const VALID_CATEGORIES = new Set<string>(AUDIT_CATEGORIES);
const VALID_OUTCOMES = new Set<string>(AUDIT_OUTCOMES);
const VALID_SEVERITIES = new Set<string>(AUDIT_SEVERITIES);
const VALID_ACTOR_TYPES = new Set<string>(AUDIT_ACTOR_TYPES);
/**
* Accepts `YYYY-MM-DD` (interpreted as UTC by the downstream Date parse) or a
* full ISO 8601 / RFC 3339 timestamp that includes either `Z` or a `±HH:MM`
* offset. Local-time strings without a zone are rejected so every input maps
* to an unambiguous instant.
* offset. Local-time strings without a zone are rejected so every input maps to
* an unambiguous instant.
*/
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2}))?$/;
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
const OBJECT_ID_RE = /^[a-fA-F0-9]{24}$/;
export interface AdminAuditLogDeps {
@ -43,10 +55,11 @@ export interface AdminAuditLogDeps {
) => Promise<AdminAuditLogEntry | null>;
streamAuditLogEntries: (
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
filters: Omit<AuditLogFilters, 'offset' | 'limit' | 'cursor'>,
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
options?: { isCancelled?: () => boolean; maxRows?: number },
) => Promise<number>;
verifyAuditChain: (tenantId: string | undefined) => Promise<AuditChainVerification>;
}
interface CallerContext {
@ -70,34 +83,42 @@ function asStringArray(v: unknown): string[] | undefined {
return undefined;
}
function parseActionFilter(raw: unknown):
| {
ok: true;
value: AuditAction[] | undefined;
}
| { ok: false; error: string } {
type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
/** Parses a repeatable enum filter (`?action=a&action=b`) against a whitelist. */
function parseEnumArray<T extends string>(
raw: unknown,
valid: Set<string>,
label: string,
): ParseResult<T[] | undefined> {
const arr = asStringArray(raw);
if (!arr || arr.length === 0) return { ok: true, value: undefined };
const invalid = arr.find((a) => !VALID_ACTIONS.has(a as AuditAction));
if (invalid != null) {
return { ok: false, error: `Unknown action: ${invalid}` };
}
return { ok: true, value: arr as AuditAction[] };
const invalid = arr.find((a) => !valid.has(a));
if (invalid != null) return { ok: false, error: `Unknown ${label}: ${invalid}` };
return { ok: true, value: arr as T[] };
}
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
function parseEnum<T extends string>(
raw: unknown,
valid: Set<string>,
label: string,
): ParseResult<T | undefined> {
if (raw == null || raw === '') return { ok: true, value: undefined };
if (typeof raw !== 'string') return { ok: false, error: `${label} must be a string` };
if (!valid.has(raw)) return { ok: false, error: `Unknown ${label}: ${raw}` };
return { ok: true, value: raw as T };
}
/**
* Parses a filter date. `boundary` controls how a bare `YYYY-MM-DD` is widened:
* `start` leaves it at the beginning of the day (00:00:00.000Z, the default for
* `from`), `end` snaps it to 23:59:59.999Z so that a `to=2025-01-15` filter
* actually includes everything that occurred on January 15 instead of cutting
* off at midnight. Full ISO timestamps are honored exactly regardless.
* `start` leaves it at 00:00:00.000Z (default for `from`), `end` snaps it to
* 23:59:59.999Z so `to=2025-01-15` includes everything on January 15 instead of
* cutting off at midnight. Full ISO timestamps are honored exactly.
*/
function parseIsoDate(
raw: unknown,
boundary: 'start' | 'end' = 'start',
): { ok: true; value?: Date } | { ok: false; error: string } {
): ParseResult<Date | undefined> {
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' };
@ -109,39 +130,20 @@ function parseIsoDate(
return { ok: true, value: d };
}
function parseLimit(
function parseIntInRange(
raw: unknown,
): { ok: true; value: number | undefined } | { ok: false; error: string } {
label: string,
min: number,
max?: number,
): ParseResult<number | undefined> {
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 > MAX_AUDIT_LOG_LIMIT)
return { ok: false, error: `limit must be <= ${MAX_AUDIT_LOG_LIMIT}` };
if (!Number.isFinite(n)) return { ok: false, error: `${label} must be a number` };
if (n < min) return { ok: false, error: `${label} must be >= ${min}` };
if (max != null && n > max) return { ok: false, error: `${label} must be <= ${max}` };
return { ok: true, value: Math.floor(n) };
}
function parseOffset(
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: 'offset must be a number' };
if (n < 0) return { ok: false, error: 'offset must be >= 0' };
return { ok: true, value: Math.floor(n) };
}
function parsePrincipalType(
raw: unknown,
): { ok: true; value: PrincipalType | undefined } | { ok: false; error: string } {
if (raw == null || raw === '') return { ok: true, value: undefined };
if (typeof raw !== 'string') return { ok: false, error: 'targetPrincipalType must be a string' };
if (!VALID_PRINCIPAL_TYPES.has(raw)) {
return { ok: false, error: `Unknown targetPrincipalType: ${raw}` };
}
return { ok: true, value: raw as PrincipalType };
}
function pickString(raw: unknown, maxLen = 256): string | undefined {
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
@ -158,15 +160,31 @@ function escapeCsvCell(value: string): string {
return guarded;
}
const CSV_COLUMNS: ReadonlyArray<{ key: keyof AdminAuditLogEntry; 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 stringifyMetaValue(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
const CSV_COLUMNS: ReadonlyArray<{ label: string; value: (e: AdminAuditLogEntry) => string }> = [
{ label: 'Timestamp', value: (e) => e.timestamp },
{ label: 'Category', value: (e) => e.category },
{ label: 'Action', value: (e) => e.action },
{ label: 'Outcome', value: (e) => e.outcome },
{ label: 'Severity', value: (e) => e.severity },
{ label: 'Actor type', value: (e) => e.actor.type },
{ label: 'Actor', value: (e) => e.actor.name },
{ label: 'Actor ID', value: (e) => e.actor.id ?? '' },
{ label: 'Target type', value: (e) => e.target.type },
{ label: 'Target', value: (e) => e.target.name ?? '' },
{ label: 'Target ID', value: (e) => e.target.id ?? '' },
{ label: 'Capability', value: (e) => stringifyMetaValue(e.metadata?.capability) },
{ label: 'Details', value: (e) => (e.metadata ? JSON.stringify(e.metadata) : '') },
{ label: 'IP', value: (e) => e.context?.ip ?? '' },
{ label: 'Request ID', value: (e) => e.context?.requestId ?? '' },
{ label: 'Seq', value: (e) => String(e.integrity.seq) },
{ label: 'Hash', value: (e) => e.integrity.hash },
];
function formatCsvHeader(): string {
@ -174,63 +192,30 @@ function formatCsvHeader(): string {
}
function formatCsvRow(entry: AdminAuditLogEntry): string {
return CSV_COLUMNS.map((c) => escapeCsvCell(String(entry[c.key] ?? ''))).join(',');
return CSV_COLUMNS.map((c) => escapeCsvCell(c.value(entry))).join(',');
}
type ParsedFilters = Omit<AuditLogFilters, 'offset' | 'limit'>;
type ParsedFilters = Omit<AuditLogFilters, 'offset' | 'limit' | 'cursor'>;
interface AuditLogQuery {
search?: string;
category?: string | string[];
action?: string | string[];
outcome?: string | string[];
severity?: string | string[];
actorType?: string;
actorQuery?: string;
targetType?: string;
targetQuery?: string;
capability?: string;
from?: string;
to?: string;
/** Substring match against the denormalized actor display name. */
actorQuery?: string;
/** @deprecated Use `actorQuery`. Still accepted as an alias. */
actorId?: string;
targetPrincipalType?: string;
/** Substring match against the denormalized target display name. */
targetQuery?: string;
/** @deprecated Use `targetQuery`. Still accepted as an alias. */
targetPrincipalId?: string;
capability?: string;
limit?: string;
offset?: string;
cursor?: string;
}
/**
* The HTTP filter keys `actorId`/`targetPrincipalId` were misnomers they
* never matched ObjectIds, they did case-insensitive substring matches on
* the denormalized display names. The new keys `actorQuery`/`targetQuery`
* describe what actually happens. The legacy names are accepted for one
* release as deprecated aliases so the sibling admin-panel PR keeps working
* while it migrates; each use emits a deprecation log.
*/
function readActorQuery(query: AuditLogQuery): string | undefined {
if (query.actorQuery != null) return pickString(query.actorQuery, 128);
if (query.actorId != null) {
logger.warn(
'[adminAuditLog] deprecated filter param `actorId` — rename to `actorQuery` (substring match on actorName)',
);
return pickString(query.actorId, 128);
}
return undefined;
}
function readTargetQuery(query: AuditLogQuery): string | undefined {
if (query.targetQuery != null) return pickString(query.targetQuery, 128);
if (query.targetPrincipalId != null) {
logger.warn(
'[adminAuditLog] deprecated filter param `targetPrincipalId` — rename to `targetQuery` (substring match on targetName)',
);
return pickString(query.targetPrincipalId, 128);
}
return undefined;
}
function parseFilters(
query: AuditLogQuery,
): { ok: true; value: ParsedFilters } | { ok: false; error: string } {
function parseFilters(query: AuditLogQuery): ParseResult<ParsedFilters> {
const from = parseIsoDate(query.from, 'start');
if (!from.ok) return { ok: false, error: `from: ${from.error}` };
const to = parseIsoDate(query.to, 'end');
@ -238,21 +223,33 @@ function parseFilters(
if (from.value && to.value && from.value > to.value) {
return { ok: false, error: '`from` must be earlier than `to`' };
}
const action = parseActionFilter(query.action);
const category = parseEnumArray<AuditCategory>(query.category, VALID_CATEGORIES, 'category');
if (!category.ok) return { ok: false, error: category.error };
const action = parseEnumArray<AuditAction>(query.action, VALID_ACTIONS, 'action');
if (!action.ok) return { ok: false, error: action.error };
const targetPrincipalType = parsePrincipalType(query.targetPrincipalType);
if (!targetPrincipalType.ok) return { ok: false, error: targetPrincipalType.error };
const outcome = parseEnumArray<AuditOutcome>(query.outcome, VALID_OUTCOMES, 'outcome');
if (!outcome.ok) return { ok: false, error: outcome.error };
const severity = parseEnumArray<AuditSeverity>(query.severity, VALID_SEVERITIES, 'severity');
if (!severity.ok) return { ok: false, error: severity.error };
const actorType = parseEnum<AuditActorType>(query.actorType, VALID_ACTOR_TYPES, 'actorType');
if (!actorType.ok) return { ok: false, error: actorType.error };
return {
ok: true,
value: {
search: pickString(query.search, 200),
category: category.value,
action: action.value,
outcome: outcome.value,
severity: severity.value,
actorType: actorType.value,
actorQuery: pickString(query.actorQuery, 128),
targetType: pickString(query.targetType, 128),
targetQuery: pickString(query.targetQuery, 128),
capability: pickString(query.capability, 256),
from: from.value,
to: to.value,
actorQuery: readActorQuery(query),
targetPrincipalType: targetPrincipalType.value,
targetQuery: readTargetQuery(query),
capability: pickString(query.capability, 256),
},
};
}
@ -260,9 +257,10 @@ function parseFilters(
export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
listAuditLog: (req: ServerRequest, res: Response) => Promise<Response>;
getAuditLogEntry: (req: ServerRequest, res: Response) => Promise<Response>;
verifyAuditLog: (req: ServerRequest, res: Response) => Promise<Response>;
exportAuditLogCsv: (req: ServerRequest, res: Response) => Promise<Response | void>;
} {
const { listAuditLogPage, findAuditLogEntry, streamAuditLogEntries } = deps;
const { listAuditLogPage, findAuditLogEntry, streamAuditLogEntries, verifyAuditChain } = deps;
async function listAuditLogHandler(req: ServerRequest, res: Response) {
try {
@ -273,15 +271,18 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
const filters = parseFilters(query);
if (!filters.ok) return res.status(400).json({ error: filters.error });
const limitResult = parseLimit(query.limit);
if (!limitResult.ok) return res.status(400).json({ error: limitResult.error });
const offsetResult = parseOffset(query.offset);
if (!offsetResult.ok) return res.status(400).json({ error: offsetResult.error });
const limit = parseIntInRange(query.limit, 'limit', 1, MAX_AUDIT_LOG_LIMIT);
if (!limit.ok) return res.status(400).json({ error: limit.error });
const offset = parseIntInRange(query.offset, 'offset', 0);
if (!offset.ok) return res.status(400).json({ error: offset.error });
const cursor = parseIntInRange(query.cursor, 'cursor', 1);
if (!cursor.ok) return res.status(400).json({ error: cursor.error });
const page = await listAuditLogPage(caller.tenantId, {
...filters.value,
offset: offsetResult.value,
limit: limitResult.value,
offset: offset.value,
limit: limit.value,
cursor: cursor.value,
});
return res.status(200).json(page);
@ -310,6 +311,19 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
}
}
async function verifyAuditLogHandler(req: ServerRequest, res: Response) {
try {
const caller = resolveCaller(req);
if (!caller) return res.status(401).json({ error: 'Authentication required' });
const result = await verifyAuditChain(caller.tenantId);
return res.status(200).json(result);
} catch (err) {
logger.error('[adminAuditLog] verify error:', err);
return res.status(500).json({ error: 'Failed to verify audit log integrity' });
}
}
async function exportAuditLogCsvHandler(req: ServerRequest, res: Response) {
try {
const caller = resolveCaller(req);
@ -324,10 +338,10 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
res.setHeader('Cache-Control', 'no-store');
/**
* The socket-`close` listener is the canonical signal it fires on
* client TCP RST as well as on graceful end. `req.aborted` (deprecated)
* is kept as a belt-and-braces fallback for Node versions that emit it
* before the response sees `close`.
* The socket-`close` listener is the canonical signal it fires on client
* TCP RST as well as on graceful end. `req.aborted` (deprecated) is kept as
* a belt-and-braces fallback for Node versions that emit it before the
* response sees `close`.
*/
let clientAborted = false;
const markAborted = () => {
@ -338,10 +352,10 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
const isCancelled = () => clientAborted;
/**
* Wait for `drain` when the kernel/socket buffer is full so we never
* queue an unbounded amount of CSV in Node memory for slow consumers.
* Race against `close` so a destroyed socket can't strand the handler
* on a `drain` that will never fire.
* Wait for `drain` when the socket buffer is full so we never queue an
* unbounded amount of CSV in Node memory for slow consumers. Race against
* `close` so a destroyed socket can't strand the handler on a `drain` that
* will never fire.
*/
const writeChunk = (chunk: string): Promise<void> => {
if (clientAborted) return Promise.resolve();
@ -390,6 +404,7 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): {
return {
listAuditLog: listAuditLogHandler,
getAuditLogEntry: getAuditLogEntryHandler,
verifyAuditLog: verifyAuditLogHandler,
exportAuditLogCsv: exportAuditLogCsvHandler,
};
}

View file

@ -1187,7 +1187,7 @@ describe('createAdminGrantsHandlers', () => {
} as { _id: Types.ObjectId; role: string; tenantId?: string };
}
it('emits a grant_assigned entry after a successful assignment', async () => {
it('emits a grant.assigned entry after a successful assignment', async () => {
const recordAuditEntry = jest.fn().mockResolvedValue(undefined);
const deps = createDeps({ recordAuditEntry });
const handlers = createAdminGrantsHandlers(deps);
@ -1199,21 +1199,19 @@ describe('createAdminGrantsHandlers', () => {
expect(recordAuditEntry).toHaveBeenCalledTimes(1);
expect(recordAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
action: 'grant_assigned',
actorId: user._id.toString(),
actorName: 'Alice Admin',
targetPrincipalType: PrincipalType.ROLE,
targetPrincipalId: 'editor',
/** ROLE targets carry the human-readable name in `principalId`, so the audit
* row stores it directly. */
targetName: 'editor',
capability: SystemCapabilities.READ_USERS,
action: 'grant.assigned',
outcome: 'success',
actor: { type: 'user', id: user._id.toString(), name: 'Alice Admin' },
/** ROLE targets carry the human-readable name in `principalId`, so the
* audit target id and name are both that role name. */
target: { type: PrincipalType.ROLE, id: 'editor', name: 'editor' },
metadata: { capability: SystemCapabilities.READ_USERS },
tenantId: 'tenant-1',
}),
);
});
it('emits a grant_removed entry when deletedCount > 0', async () => {
it('emits a grant.removed entry when deletedCount > 0', async () => {
const recordAuditEntry = jest.fn().mockResolvedValue(undefined);
const deps = createDeps({
revokeCapability: jest.fn().mockResolvedValue({ deletedCount: 1 }),
@ -1228,11 +1226,9 @@ describe('createAdminGrantsHandlers', () => {
expect(recordAuditEntry).toHaveBeenCalledTimes(1);
expect(recordAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
action: 'grant_removed',
actorName: 'alice',
targetPrincipalType: PrincipalType.ROLE,
targetPrincipalId: 'editor',
targetName: 'editor',
action: 'grant.removed',
actor: expect.objectContaining({ type: 'user', name: 'alice' }),
target: { type: PrincipalType.ROLE, id: 'editor', name: 'editor' },
}),
);
});
@ -1246,5 +1242,31 @@ describe('createAdminGrantsHandlers', () => {
expect(status).toHaveBeenCalledWith(201);
});
it('fail-open: a failed audit write does not fail the grant', async () => {
const recordAuditEntry = jest.fn().mockRejectedValue(new Error('audit down'));
const deps = createDeps({ recordAuditEntry });
const handlers = createAdminGrantsHandlers(deps);
const { req, res, status } = createReqRes({ body: assignBody, user: reqUser() });
await handlers.assignGrant(req, res);
expect(status).toHaveBeenCalledWith(201);
});
it('fail-closed: a failed audit write surfaces as a 500', async () => {
const recordAuditEntry = jest.fn().mockRejectedValue(new Error('audit down'));
const deps = createDeps({ recordAuditEntry, auditFailClosed: true });
const handlers = createAdminGrantsHandlers(deps);
const { req, res, status } = createReqRes({ body: assignBody, user: reqUser() });
await handlers.assignGrant(req, res);
expect(recordAuditEntry).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ failClosed: true }),
);
expect(status).toHaveBeenCalledWith(500);
});
});
});

View file

@ -7,8 +7,10 @@ import {
} from '@librechat/data-schemas';
import type {
AuditAction,
AuditContext,
ISystemGrant,
RecordAuditEntryInput,
RecordAuditEntryOptions,
SystemCapability,
} from '@librechat/data-schemas';
import type { Response } from 'express';
@ -77,8 +79,38 @@ 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: RecordAuditEntryInput) => Promise<void>;
/** Optional audit emission. Failure is logged but does not roll back the grant
* unless `auditFailClosed` is set. */
recordAuditEntry?: (
input: RecordAuditEntryInput,
options?: RecordAuditEntryOptions,
) => Promise<void>;
/**
* When true, a failed audit write surfaces as a 5xx instead of being
* swallowed. The grant itself is already persisted (and grant writes are
* idempotent upserts), so a client retry is safe; an operator must reconcile
* the missing audit row. Defaults to fail-open.
*/
auditFailClosed?: boolean;
}
/** Normalizes a possibly-repeated header to its first string value. */
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) return value[0];
return typeof value === 'string' ? value : undefined;
}
/** Extracts forensic request context (IP, user agent, correlation id) for the
* audit record. Fields are undefined when the data isn't available. */
function buildAuditContext(req: ServerRequest): AuditContext {
const headers = req.headers ?? {};
const forwarded = firstHeaderValue(headers['x-forwarded-for']);
const forwardedIp = forwarded ? forwarded.split(',')[0]?.trim() : undefined;
return {
ip: req.ip || forwardedIp || req.socket?.remoteAddress || undefined,
userAgent: firstHeaderValue(headers['user-agent']),
requestId: firstHeaderValue(headers['x-request-id'] ?? headers['x-correlation-id']),
};
}
/** Currently ROLE-only; Record/Set structure preserved for future principal-type expansion. */
@ -105,6 +137,7 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): {
getCachedPrincipals,
checkRoleExists,
recordAuditEntry,
auditFailClosed,
} = deps;
async function emitAudit(args: {
@ -113,27 +146,32 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): {
principalType: PrincipalType;
principalId: string;
capability: SystemCapability;
context?: AuditContext;
}): Promise<void> {
if (!recordAuditEntry) return;
/** The grants surface is ROLE-only today (see `MANAGE_CAPABILITY_BY_TYPE`),
* and SystemGrant stores the human-readable role name in `principalId` for
* ROLE principals, so the audit target's id and name are both `principalId`.
* When USER and GROUP grants are enabled, resolve the display name here. */
const input: RecordAuditEntryInput = {
action: args.action,
outcome: 'success',
severity: 'warning',
actor: { type: 'user', id: args.caller.userId, name: args.caller.actorName },
target: { type: args.principalType, id: args.principalId, name: args.principalId },
metadata: { capability: args.capability },
context: args.context,
tenantId: args.caller.tenantId,
};
if (auditFailClosed) {
/** Let the failure propagate to the handler (→ 5xx); see `auditFailClosed`. */
await recordAuditEntry(input, { failClosed: true });
return;
}
try {
/** The grants surface is ROLE-only today (see `MANAGE_CAPABILITY_BY_TYPE`),
* and SystemGrant stores the human-readable role name in `principalId` for
* ROLE principals, so the audit row's `targetName` is just `principalId`.
* When USER and GROUP grants are enabled, a `resolveTargetName` dep should
* be added here to look up the display name; until then it would be dead
* code. */
await recordAuditEntry({
action: args.action,
actorId: args.caller.userId,
actorName: args.caller.actorName,
targetPrincipalType: args.principalType,
targetPrincipalId: args.principalId,
targetName: args.principalId,
capability: args.capability,
tenantId: args.caller.tenantId,
});
await recordAuditEntry(input);
} catch (err) {
/** Audit failure must not roll back the grant: log and move on. */
/** Fail-open: audit failure must not roll back the grant. */
logger.error('[adminGrants] audit write failed', err);
}
}
@ -395,11 +433,12 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): {
return res.status(500).json({ error: 'Grant operation returned no result' });
}
await emitAudit({
action: 'grant_assigned',
action: 'grant.assigned',
caller,
principalType,
principalId,
capability,
context: buildAuditContext(req),
});
return res.status(201).json({ grant });
} catch (error) {
@ -452,11 +491,12 @@ export function createAdminGrantsHandlers(deps: AdminGrantsDeps): {
// trail or the forensic record becomes misleading.
if (revokeResult.deletedCount > 0) {
await emitAudit({
action: 'grant_removed',
action: 'grant.removed',
caller,
principalType: principalType as PrincipalType,
principalId,
capability: capability as SystemCapability,
context: buildAuditContext(req),
});
}
return res.status(200).json({ success: true });

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/data-schemas",
"version": "0.0.53",
"version": "0.0.54",
"description": "Mongoose schemas and models for LibreChat",
"type": "module",
"main": "dist/index.cjs",

View file

@ -24,12 +24,21 @@ export {
validateSkillFrontmatter,
validateSkillDescription,
deriveStructuredFrontmatterFields,
AUDIT_SCHEMA_VERSION,
MAX_AUDIT_EXPORT_ROWS,
MAX_AUDIT_LOG_LIMIT,
} from './methods';
export type * from './types';
export type * from './methods';
export { AUDIT_ACTIONS } from './types/admin';
export {
AUDIT_ACTIONS,
AUDIT_CATEGORIES,
AUDIT_OUTCOMES,
AUDIT_SEVERITIES,
AUDIT_ACTOR_TYPES,
AUDIT_ACTION_CATEGORY,
} from './types/admin';
export { GENESIS_HASH, PLATFORM_CHAIN_KEY } from './schema/auditLog';
export { default as logger } from './config/winston';
export { default as meiliLogger } from './config/meiliLogger';
export { redactMessage } from './config/parsers';

View file

@ -1,10 +1,9 @@
import mongoose, { Types } from 'mongoose';
import { PrincipalType } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { RecordAuditEntryInput } from '~/types';
import type * as t from '~/types';
import auditLogSchema, { GENESIS_HASH, PLATFORM_CHAIN_KEY } from '~/schema/auditLog';
import { createAuditLogMethods } from './auditLog';
import auditLogSchema from '~/schema/auditLog';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@ -18,27 +17,30 @@ let AuditLog: mongoose.Model<t.IAuditLog>;
let methods: ReturnType<typeof createAuditLogMethods>;
const actorObjectId = new Types.ObjectId();
const targetObjectId = new Types.ObjectId();
function baseInput(over: Partial<RecordAuditEntryInput> = {}): RecordAuditEntryInput {
return {
action: 'grant_assigned',
actorId: actorObjectId,
actorName: 'Alice Admin',
targetPrincipalType: PrincipalType.USER,
targetPrincipalId: targetObjectId,
targetName: 'Bob User',
capability: 'manage:users',
action: 'grant.assigned',
actor: { type: 'user', id: actorObjectId, name: 'Alice Admin' },
target: { type: 'role', id: 'ADMIN', name: 'ADMIN' },
metadata: { capability: 'manage:users' },
tenantId: 'tenant-a',
...over,
};
}
async function seed(count: number, over: Partial<RecordAuditEntryInput> = {}): Promise<void> {
for (let i = 0; i < count; i++) {
await methods.recordAuditEntry(baseInput({ metadata: { capability: `cap:${i}` }, ...over }));
}
}
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
AuditLog = mongoose.models.AuditLog || mongoose.model<t.IAuditLog>('AuditLog', auditLogSchema);
methods = createAuditLogMethods(mongoose);
await mongoose.connect(mongoServer.getUri());
await AuditLog.init();
});
afterAll(async () => {
@ -47,254 +49,327 @@ afterAll(async () => {
});
beforeEach(async () => {
// AuditLog enforces append-only via pre-hooks, so reset between tests by
// dropping the collection at the raw driver level (bypasses model hooks).
await AuditLog.collection.deleteMany({});
});
describe('auditLog methods', () => {
describe('recordAuditEntry', () => {
it('persists a tenant-scoped entry and wires denormalized fields through', async () => {
it('persists a tenant-scoped entry with denormalized + derived fields', async () => {
const doc = await methods.recordAuditEntry(baseInput());
expect(doc).not.toBeNull();
const persisted = await AuditLog.findOne({ tenantId: 'tenant-a' }).lean();
expect(persisted?.actorName).toBe('Alice Admin');
expect(persisted?.targetName).toBe('Bob User');
expect(persisted?.capability).toBe('manage:users');
const persisted = await AuditLog.findOne({ chainKey: 'tenant-a' }).lean();
expect(persisted).toMatchObject({
schemaVersion: 1,
category: 'grant',
action: 'grant.assigned',
outcome: 'success',
severity: 'info',
actor: { type: 'user', id: actorObjectId.toString(), name: 'Alice Admin' },
target: { type: 'role', id: 'ADMIN', name: 'ADMIN' },
metadata: { capability: 'manage:users' },
tenantId: 'tenant-a',
chainKey: 'tenant-a',
seq: 1,
prevHash: GENESIS_HASH,
});
expect(persisted?.hash).toMatch(/^[a-f0-9]{64}$/);
expect(persisted?.createdAt).toBeInstanceOf(Date);
});
it('omits the tenantId field entirely for platform-level entries', async () => {
it('derives a warning severity for non-success outcomes', async () => {
await methods.recordAuditEntry(baseInput({ outcome: 'denied' }));
const persisted = await AuditLog.findOne({ chainKey: 'tenant-a' }).lean();
expect(persisted?.outcome).toBe('denied');
expect(persisted?.severity).toBe('warning');
});
it('starts each chain at the genesis hash and links subsequent entries', async () => {
const first = await methods.recordAuditEntry(baseInput());
const second = await methods.recordAuditEntry(baseInput());
expect(first?.seq).toBe(1);
expect(first?.prevHash).toBe(GENESIS_HASH);
expect(second?.seq).toBe(2);
expect(second?.prevHash).toBe(first?.hash);
});
it('keeps tenant and platform chains independent', async () => {
await methods.recordAuditEntry(baseInput({ tenantId: 'tenant-a' }));
await methods.recordAuditEntry(baseInput({ tenantId: 'tenant-b' }));
await methods.recordAuditEntry(baseInput({ tenantId: undefined }));
const raw = await AuditLog.collection.findOne({});
expect(raw && Object.prototype.hasOwnProperty.call(raw, 'tenantId')).toBe(false);
const a = await AuditLog.findOne({ chainKey: 'tenant-a' }).lean();
const b = await AuditLog.findOne({ chainKey: 'tenant-b' }).lean();
const platform = await AuditLog.findOne({ chainKey: PLATFORM_CHAIN_KEY }).lean();
expect(a?.seq).toBe(1);
expect(b?.seq).toBe(1);
expect(platform?.seq).toBe(1);
expect(platform?.tenantId).toBeUndefined();
});
it('treats blank and whitespace-only tenantId as platform-level (no silent drop)', async () => {
const blank = await methods.recordAuditEntry(baseInput({ tenantId: '' }));
const whitespace = await methods.recordAuditEntry(baseInput({ tenantId: ' ' }));
expect(blank).not.toBeNull();
expect(whitespace).not.toBeNull();
const rows = await AuditLog.collection.find({}).toArray();
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(Object.prototype.hasOwnProperty.call(row, 'tenantId')).toBe(false);
}
it('treats a blank tenantId as platform scope (no literal "" stored)', async () => {
await methods.recordAuditEntry(baseInput({ tenantId: ' ' }));
const platform = await AuditLog.findOne({ chainKey: PLATFORM_CHAIN_KEY }).lean();
expect(platform).not.toBeNull();
expect(platform?.tenantId).toBeUndefined();
const blank = await AuditLog.findOne({ tenantId: '' }).lean();
expect(blank).toBeNull();
});
});
describe('listAuditLogPage', () => {
beforeEach(async () => {
await methods.recordAuditEntry(baseInput({ capability: 'manage:users' }));
it('omits metadata/context entirely when empty', async () => {
await methods.recordAuditEntry(
baseInput({ action: 'grant_removed', capability: 'manage:roles' }),
baseInput({ metadata: {}, context: { ip: '', userAgent: undefined } }),
);
await methods.recordAuditEntry(baseInput({ tenantId: 'tenant-b', capability: 'read:users' }));
const persisted = await AuditLog.findOne({ chainKey: 'tenant-a' }).lean();
expect(persisted?.metadata).toBeUndefined();
expect(persisted?.context).toBeUndefined();
});
it('persists request context when provided', async () => {
await methods.recordAuditEntry(
baseInput({ tenantId: undefined, capability: 'access:admin' }),
baseInput({ context: { ip: '10.0.0.1', userAgent: 'jest', requestId: 'req-1' } }),
);
});
it('returns only the calling tenant by default', async () => {
const page = await methods.listAuditLogPage('tenant-a', {});
expect(page.total).toBe(2);
const tenants = new Set(page.entries.map((e) => e.capability));
expect(tenants).toEqual(new Set(['manage:users', 'manage:roles']));
});
it('only returns platform-level entries when tenantId is omitted', async () => {
const page = await methods.listAuditLogPage(undefined, {});
expect(page.total).toBe(1);
expect(page.entries[0]?.capability).toBe('access:admin');
});
it('filters by a single action', async () => {
const page = await methods.listAuditLogPage('tenant-a', { action: ['grant_removed'] });
expect(page.total).toBe(1);
expect(page.entries[0]?.action).toBe('grant_removed');
});
it('filters by multiple actions via $in', async () => {
const page = await methods.listAuditLogPage('tenant-a', {
action: ['grant_assigned', 'grant_removed'],
const persisted = await AuditLog.findOne({ chainKey: 'tenant-a' }).lean();
expect(persisted?.context).toMatchObject({
ip: '10.0.0.1',
userAgent: 'jest',
requestId: 'req-1',
});
expect(page.total).toBe(2);
});
it('partial-matches the actorQuery param against actorName', async () => {
await methods.recordAuditEntry(baseInput({ actorName: 'Charlotte Auditor' }));
const page = await methods.listAuditLogPage('tenant-a', { actorQuery: 'charlotte' });
expect(page.total).toBe(1);
expect(page.entries[0]?.actorName).toBe('Charlotte Auditor');
});
it('fail-open returns null on write failure; fail-closed throws', async () => {
const spy = jest
.spyOn(AuditLog, 'create')
.mockRejectedValueOnce(new Error('boom'))
.mockRejectedValueOnce(new Error('boom'));
it('partial-matches the targetQuery param against targetName', async () => {
await methods.recordAuditEntry(baseInput({ targetName: 'Daphne Director' }));
const page = await methods.listAuditLogPage('tenant-a', { targetQuery: 'daphne' });
expect(page.total).toBe(1);
expect(page.entries[0]?.targetName).toBe('Daphne Director');
});
await expect(methods.recordAuditEntry(baseInput())).resolves.toBeNull();
await expect(methods.recordAuditEntry(baseInput(), { failClosed: true })).rejects.toThrow(
'boom',
);
it('treats empty-string tenantId as platform-level (not a literal `""` tenant match)', async () => {
const page = await methods.listAuditLogPage('', {});
expect(page.total).toBe(1);
expect(page.entries[0]?.capability).toBe('access:admin');
});
it('treats whitespace-only tenantId as platform-level', async () => {
const page = await methods.listAuditLogPage(' ', {});
expect(page.total).toBe(1);
expect(page.entries[0]?.capability).toBe('access:admin');
});
it('partial-matches the capability filter case-insensitively', async () => {
const page = await methods.listAuditLogPage('tenant-a', { capability: 'ROLES' });
expect(page.total).toBe(1);
expect(page.entries[0]?.capability).toBe('manage:roles');
});
it('applies a `from` / `to` window using createdAt', async () => {
const allBefore = await AuditLog.find({ tenantId: 'tenant-a' }).sort({ createdAt: 1 }).lean();
const cutoff = allBefore[0]!.createdAt;
const after = new Date(cutoff.getTime() + 1);
const page = await methods.listAuditLogPage('tenant-a', { from: after });
expect(page.total).toBeLessThan(2);
});
it('paginates by offset and limit and reports the total of the full match set', async () => {
const page1 = await methods.listAuditLogPage('tenant-a', { offset: 0, limit: 1 });
const page2 = await methods.listAuditLogPage('tenant-a', { offset: 1, limit: 1 });
expect(page1.total).toBe(2);
expect(page2.total).toBe(2);
expect(page1.entries.length).toBe(1);
expect(page2.entries.length).toBe(1);
expect(page1.entries[0]?.id).not.toBe(page2.entries[0]?.id);
});
it('stringifies ObjectIds and dates on the wire', async () => {
const page = await methods.listAuditLogPage('tenant-a', { limit: 1 });
const entry = page.entries[0]!;
expect(typeof entry.id).toBe('string');
expect(typeof entry.actorId).toBe('string');
expect(typeof entry.targetPrincipalId).toBe('string');
expect(typeof entry.timestamp).toBe('string');
expect(() => new Date(entry.timestamp).toISOString()).not.toThrow();
});
it('escapes regex metacharacters in the search input', async () => {
await methods.recordAuditEntry(baseInput({ actorName: 'risky.dot+plus' }));
const page = await methods.listAuditLogPage('tenant-a', { search: 'risky.dot+plus' });
expect(page.total).toBe(1);
spy.mockRestore();
});
});
describe('append-only enforcement', () => {
it('rejects updateOne against any audit entry', async () => {
const doc = await methods.recordAuditEntry(baseInput());
await expect(
AuditLog.updateOne({ _id: doc!._id }, { capability: 'tampered' }),
).rejects.toThrow(/append-only/);
});
it('rejects findOneAndUpdate against any audit entry', async () => {
const doc = await methods.recordAuditEntry(baseInput());
await expect(
AuditLog.findOneAndUpdate({ _id: doc!._id }, { capability: 'tampered' }),
).rejects.toThrow(/append-only/);
});
it('rejects deleteOne against any audit entry', async () => {
const doc = await methods.recordAuditEntry(baseInput());
await expect(AuditLog.deleteOne({ _id: doc!._id })).rejects.toThrow(/append-only/);
});
it('rejects deleteMany', async () => {
it('rejects every query-level update and delete path', async () => {
await methods.recordAuditEntry(baseInput());
await expect(AuditLog.deleteMany({})).rejects.toThrow(/append-only/);
await expect(
AuditLog.updateOne({ chainKey: 'tenant-a' }, { action: 'grant.removed' }),
).rejects.toThrow(/append-only/);
await expect(AuditLog.updateMany({}, { seq: 9 })).rejects.toThrow(/append-only/);
await expect(AuditLog.findOneAndUpdate({}, { seq: 9 })).rejects.toThrow(/append-only/);
await expect(AuditLog.deleteOne({ chainKey: 'tenant-a' })).rejects.toThrow(/append-only/);
await expect(AuditLog.deleteMany({ chainKey: 'tenant-a' })).rejects.toThrow(/append-only/);
await expect(AuditLog.findOneAndDelete({})).rejects.toThrow(/append-only/);
});
it('rejects Document.prototype.deleteOne() on a loaded audit entry', async () => {
const doc = await methods.recordAuditEntry(baseInput());
const persisted = (await AuditLog.findById(doc!._id))!;
await expect(persisted.deleteOne()).rejects.toThrow(/append-only/);
const stillThere = await AuditLog.findById(doc!._id);
expect(stillThere).not.toBeNull();
it('rejects document-level re-save and deleteOne', async () => {
await methods.recordAuditEntry(baseInput());
const doc = await AuditLog.findOne({ chainKey: 'tenant-a' });
expect(doc).not.toBeNull();
await expect(doc!.save()).rejects.toThrow(/append-only/);
await expect(doc!.deleteOne()).rejects.toThrow(/append-only/);
});
});
describe('listAuditLogPage', () => {
it('returns newest-first entries with total and nextCursor', async () => {
await seed(3);
const page = await methods.listAuditLogPage('tenant-a', { limit: 2 });
expect(page.total).toBe(3);
expect(page.entries).toHaveLength(2);
expect(page.entries[0].integrity.seq).toBe(3);
expect(page.entries[1].integrity.seq).toBe(2);
expect(page.nextCursor).toBe(2);
});
it('rejects Document.prototype.updateOne() on a loaded audit entry', async () => {
const doc = await methods.recordAuditEntry(baseInput());
const persisted = (await AuditLog.findById(doc!._id))!;
await expect(persisted.updateOne({ capability: 'tampered' })).rejects.toThrow(/append-only/);
const unchanged = await AuditLog.findById(doc!._id);
expect(unchanged?.capability).toBe('manage:users');
it('paginates by keyset cursor without duplicating rows under concurrent appends', async () => {
await seed(4);
const page1 = await methods.listAuditLogPage('tenant-a', { limit: 2 });
const seenIds = new Set(page1.entries.map((e) => e.id));
// a new entry lands at the head between page fetches
await methods.recordAuditEntry(baseInput());
const page2 = await methods.listAuditLogPage('tenant-a', {
limit: 2,
cursor: page1.nextCursor ?? undefined,
});
for (const entry of page2.entries) {
expect(seenIds.has(entry.id)).toBe(false);
}
expect(page2.entries.map((e) => e.integrity.seq)).toEqual([2, 1]);
expect(page2.nextCursor).toBeNull();
});
it('rejects a second save() on an existing document', async () => {
const doc = await methods.recordAuditEntry(baseInput());
const persisted = (await AuditLog.findById(doc!._id))!;
persisted.capability = 'tampered';
await expect(persisted.save()).rejects.toThrow(/append-only/);
it('supports offset pagination', async () => {
await seed(3);
const page = await methods.listAuditLogPage('tenant-a', { limit: 1, offset: 1 });
expect(page.entries[0].integrity.seq).toBe(2);
});
it('does not stamp updatedAt on new documents', async () => {
it('isolates tenants', async () => {
await methods.recordAuditEntry(baseInput({ tenantId: 'tenant-a' }));
await methods.recordAuditEntry(baseInput({ tenantId: 'tenant-b' }));
const page = await methods.listAuditLogPage('tenant-a', {});
expect(page.total).toBe(1);
expect(page.entries[0].tenantId).toBe('tenant-a');
});
it('filters by action, outcome, severity, and actorType', async () => {
await methods.recordAuditEntry(baseInput({ action: 'grant.assigned' }));
await methods.recordAuditEntry(baseInput({ action: 'grant.removed', outcome: 'denied' }));
expect(
(await methods.listAuditLogPage('tenant-a', { action: ['grant.removed'] })).total,
).toBe(1);
expect((await methods.listAuditLogPage('tenant-a', { outcome: ['denied'] })).total).toBe(1);
expect((await methods.listAuditLogPage('tenant-a', { severity: ['warning'] })).total).toBe(1);
expect((await methods.listAuditLogPage('tenant-a', { actorType: 'user' })).total).toBe(2);
expect((await methods.listAuditLogPage('tenant-a', { actorType: 'agent' })).total).toBe(0);
});
it('substring-matches actorQuery, targetQuery, and capability case-insensitively', async () => {
await methods.recordAuditEntry(
baseInput({
actor: { type: 'user', id: actorObjectId, name: 'Alice Admin' },
target: { type: 'role', id: 'ADMIN', name: 'ADMIN' },
metadata: { capability: 'manage:users' },
}),
);
expect((await methods.listAuditLogPage('tenant-a', { actorQuery: 'alice' })).total).toBe(1);
expect((await methods.listAuditLogPage('tenant-a', { targetQuery: 'admin' })).total).toBe(1);
expect((await methods.listAuditLogPage('tenant-a', { capability: 'manage' })).total).toBe(1);
expect((await methods.listAuditLogPage('tenant-a', { actorQuery: 'zzz' })).total).toBe(0);
});
it('filters by date window', async () => {
await seed(2);
const future = new Date(Date.now() + 60_000);
const past = new Date(Date.now() - 60_000);
expect((await methods.listAuditLogPage('tenant-a', { from: past, to: future })).total).toBe(
2,
);
expect((await methods.listAuditLogPage('tenant-a', { from: future })).total).toBe(0);
});
});
describe('findAuditLogEntry', () => {
it('returns a single entry scoped to its tenant', async () => {
const doc = await methods.recordAuditEntry(baseInput());
const raw = await AuditLog.collection.findOne({ _id: doc!._id });
expect(raw && Object.prototype.hasOwnProperty.call(raw, 'updatedAt')).toBe(false);
const id = doc!._id.toString();
const found = await methods.findAuditLogEntry('tenant-a', id);
expect(found?.id).toBe(id);
expect(await methods.findAuditLogEntry('tenant-b', id)).toBeNull();
});
it('returns null for a non-ObjectId id', async () => {
expect(await methods.findAuditLogEntry('tenant-a', 'not-an-id')).toBeNull();
});
});
describe('streamAuditLogEntries', () => {
it('invokes onEntry for every match without skipping tenant boundaries', async () => {
await methods.recordAuditEntry(baseInput({ capability: 'manage:users' }));
await methods.recordAuditEntry(baseInput({ capability: 'manage:roles' }));
await methods.recordAuditEntry(baseInput({ tenantId: 'tenant-b' }));
const collected: string[] = [];
const count = await methods.streamAuditLogEntries('tenant-a', {}, (entry) => {
collected.push(entry.capability);
it('streams all matching rows newest-first', async () => {
await seed(3);
const seen: number[] = [];
const count = await methods.streamAuditLogEntries('tenant-a', {}, (e) => {
seen.push(e.integrity.seq);
});
expect(count).toBe(2);
expect(new Set(collected)).toEqual(new Set(['manage:users', 'manage:roles']));
});
it('stops iterating and closes the cursor when isCancelled becomes true', async () => {
for (let i = 0; i < 10; i++) {
await methods.recordAuditEntry(baseInput({ capability: `manage:cap-${i}` }));
}
let cancelled = false;
const seen: string[] = [];
const count = await methods.streamAuditLogEntries(
'tenant-a',
{},
(entry) => {
seen.push(entry.capability);
if (seen.length >= 3) cancelled = true;
},
{ isCancelled: () => cancelled },
);
expect(count).toBe(3);
expect(seen.length).toBe(3);
expect(seen).toEqual([3, 2, 1]);
});
it('stops after maxRows entries even if more remain', async () => {
for (let i = 0; i < 10; i++) {
await methods.recordAuditEntry(baseInput({ capability: `manage:cap-${i}` }));
}
it('honors isCancelled and maxRows', async () => {
await seed(5);
const cancelled = await methods.streamAuditLogEntries('tenant-a', {}, () => {}, {
isCancelled: () => true,
});
expect(cancelled).toBe(0);
const seen: string[] = [];
const count = await methods.streamAuditLogEntries(
'tenant-a',
{},
(entry) => {
seen.push(entry.capability);
},
{ maxRows: 4 },
const capped = await methods.streamAuditLogEntries('tenant-a', {}, () => {}, { maxRows: 2 });
expect(capped).toBe(2);
});
});
describe('verifyAuditChain', () => {
it('verifies an intact chain', async () => {
await seed(4);
const result = await methods.verifyAuditChain('tenant-a');
expect(result.ok).toBe(true);
expect(result.checked).toBe(4);
expect(result.range).toEqual({ firstSeq: 1, lastSeq: 4 });
});
it('reports ok for an empty chain', async () => {
const result = await methods.verifyAuditChain('tenant-a');
expect(result.ok).toBe(true);
expect(result.checked).toBe(0);
});
it('detects a tampered field (hash mismatch)', async () => {
await seed(3);
// mutate a field via the raw driver, bypassing the append-only hooks
await AuditLog.collection.updateOne(
{ chainKey: 'tenant-a', seq: 2 },
{ $set: { 'actor.name': 'Mallory' } },
);
const result = await methods.verifyAuditChain('tenant-a');
expect(result.ok).toBe(false);
expect(result.brokenAt).toBe(2);
expect(result.reason).toBe('hash mismatch');
});
expect(count).toBe(4);
expect(seen.length).toBe(4);
it('detects a deleted entry (sequence gap)', async () => {
await seed(3);
await AuditLog.collection.deleteOne({ chainKey: 'tenant-a', seq: 2 });
const result = await methods.verifyAuditChain('tenant-a');
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/sequence gap/);
});
it('detects a forged hash link', async () => {
await seed(3);
await AuditLog.collection.updateOne(
{ chainKey: 'tenant-a', seq: 2 },
{ $set: { hash: 'f'.repeat(64) } },
);
const result = await methods.verifyAuditChain('tenant-a');
expect(result.ok).toBe(false);
expect(result.brokenAt).toBe(2);
});
});
describe('purgeAuditLogEntries', () => {
it('is a no-op unless confirmed', async () => {
await seed(2);
const result = await methods.purgeAuditLogEntries('tenant-a', {
before: new Date(Date.now() + 60_000),
confirm: false,
});
expect(result.deletedCount).toBe(0);
expect(await AuditLog.countDocuments({ chainKey: 'tenant-a' })).toBe(2);
});
it('purges a prefix and leaves the chain verifiable from a checkpoint', async () => {
// space writes so createdAt is strictly increasing for a deterministic cutoff
for (let i = 0; i < 4; i++) {
await methods.recordAuditEntry(baseInput());
await new Promise((resolve) => setTimeout(resolve, 5));
}
const third = await AuditLog.findOne({ chainKey: 'tenant-a', seq: 3 }).lean();
const cutoff = third!.createdAt;
const result = await methods.purgeAuditLogEntries('tenant-a', {
before: cutoff,
confirm: true,
});
expect(result.deletedCount).toBeGreaterThanOrEqual(1);
expect(result.checkpoint?.seq).toBeGreaterThan(1);
const verification = await methods.verifyAuditChain('tenant-a');
expect(verification.ok).toBe(true);
expect(verification.range?.firstSeq).toBeGreaterThan(1);
});
});
});

View file

@ -1,26 +1,43 @@
import { createHash } from 'node:crypto';
import type { FilterQuery, Model } from 'mongoose';
import type {
AdminAuditLogEntry,
AuditChainVerification,
AuditContext,
AuditLogFilters,
AuditLogPage,
AuditMetadata,
IAuditLog,
PurgeAuditLogOptions,
PurgeAuditLogResult,
RecordAuditEntryInput,
RecordAuditEntryOptions,
} from '~/types';
import { GENESIS_HASH, PLATFORM_CHAIN_KEY } from '~/schema/auditLog';
import { AUDIT_ACTION_CATEGORY } from '~/types/admin';
import logger from '~/config/winston';
const DEFAULT_LIMIT = 100;
export const MAX_AUDIT_LOG_LIMIT = 500;
/**
* Upper bound on rows emitted by the CSV export stream. At 100k rows per
* tenant per export request, a careless admin script (or a hostile auditor)
* can keep a cursor and a Node worker busy without saturating either; beyond
* that, exports should be sliced by `from`/`to`.
* Upper bound on rows emitted by the CSV export stream. At 100k rows per tenant
* per export request, a careless admin script (or a hostile auditor) can keep a
* cursor and a Node worker busy without saturating either; beyond that, exports
* should be sliced by `from`/`to`.
*/
export const MAX_AUDIT_EXPORT_ROWS = 100_000;
/** Record-format version stamped on every new entry. */
export const AUDIT_SCHEMA_VERSION = 1;
const MAX_SEARCH_LENGTH = 200;
/** Bounded retries when concurrent appends race for the same `seq`. */
const MAX_APPEND_RETRIES = 5;
const OBJECT_ID_RE = /^[a-fA-F0-9]{24}$/;
export interface AuditLogMethods {
recordAuditEntry: (input: RecordAuditEntryInput) => Promise<IAuditLog | null>;
recordAuditEntry: (
input: RecordAuditEntryInput,
options?: RecordAuditEntryOptions,
) => Promise<IAuditLog | null>;
listAuditLogPage: (
tenantId: string | undefined,
filters: AuditLogFilters,
@ -31,67 +48,201 @@ export interface AuditLogMethods {
) => Promise<AdminAuditLogEntry | null>;
streamAuditLogEntries: (
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
filters: Omit<AuditLogFilters, 'offset' | 'limit' | 'cursor'>,
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
options?: { isCancelled?: () => boolean; maxRows?: number },
) => Promise<number>;
verifyAuditChain: (tenantId: string | undefined) => Promise<AuditChainVerification>;
purgeAuditLogEntries: (
tenantId: string | undefined,
options: PurgeAuditLogOptions,
) => Promise<PurgeAuditLogResult>;
}
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function toWire(doc: IAuditLog): AdminAuditLogEntry {
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 regexFilter(value: string): { $regex: string; $options: string } {
return { $regex: escapeRegex(value), $options: 'i' };
}
function tenantFilter(tenantId?: string): FilterQuery<IAuditLog> {
return typeof tenantId === 'string' && tenantId.trim().length > 0
? { tenantId }
: { tenantId: { $exists: false } };
function oneOrIn<T>(values: T[]): T | { $in: T[] } {
return values.length === 1 ? values[0] : { $in: values };
}
function idToString(id: string | { toString(): string }): string {
return typeof id === 'string' ? id : id.toString();
}
function isDuplicateKeyError(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: number }).code === 11000;
}
/** Tenant scope → chain key. Blank/whitespace tenant is the platform chain. */
function resolveChainKey(tenantId?: string): string {
return typeof tenantId === 'string' && tenantId.trim().length > 0 ? tenantId : PLATFORM_CHAIN_KEY;
}
function normalizeTenantId(tenantId?: string): string | undefined {
return typeof tenantId === 'string' && tenantId.trim().length > 0 ? tenantId : undefined;
}
/** Drop undefined values; collapse an empty map to `undefined` so the field is
* omitted entirely (and hashes identically on read-back). */
function normalizeMetadata(metadata?: AuditMetadata): AuditMetadata | undefined {
if (!metadata) return undefined;
const out: AuditMetadata = {};
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined) out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function normalizeContext(context?: AuditContext): AuditContext | undefined {
if (!context) return undefined;
const out: AuditContext = {};
if (context.requestId) out.requestId = context.requestId;
if (context.ip) out.ip = context.ip;
if (context.userAgent) out.userAgent = context.userAgent;
if (context.sessionId) out.sessionId = context.sessionId;
return Object.keys(out).length > 0 ? out : undefined;
}
/** Deterministic JSON: object keys sorted recursively so two semantically equal
* entries always serialize to the same string. */
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`).join(',')}}`;
}
/** Fields covered by the per-entry hash. Mirrors the stored shape so write-time
* and verify-time recomputation agree exactly. */
interface HashableEntry {
schemaVersion: number;
category: IAuditLog['category'];
action: IAuditLog['action'];
outcome: IAuditLog['outcome'];
severity: IAuditLog['severity'];
actor: IAuditLog['actor'];
target: IAuditLog['target'];
metadata?: AuditMetadata;
context?: AuditContext;
tenantId?: string;
chainKey: string;
seq: number;
prevHash: string;
createdAt: Date;
}
function computeEntryHash(entry: HashableEntry): string {
const canonical = {
v: entry.schemaVersion,
category: entry.category,
action: entry.action,
outcome: entry.outcome,
severity: entry.severity,
actor: {
type: entry.actor.type,
id: entry.actor.id ?? null,
name: entry.actor.name,
},
target: {
type: entry.target.type,
id: entry.target.id ?? null,
name: entry.target.name ?? null,
},
metadata: entry.metadata ?? null,
context: entry.context
? {
requestId: entry.context.requestId ?? null,
ip: entry.context.ip ?? null,
userAgent: entry.context.userAgent ?? null,
sessionId: entry.context.sessionId ?? null,
}
: null,
tenantId: entry.tenantId ?? null,
chainKey: entry.chainKey,
seq: entry.seq,
prevHash: entry.prevHash,
createdAt: entry.createdAt.toISOString(),
};
return createHash('sha256').update(stableStringify(canonical)).digest('hex');
}
function toWire(doc: IAuditLog): AdminAuditLogEntry {
const entry: AdminAuditLogEntry = {
id: doc._id.toString(),
schemaVersion: doc.schemaVersion,
category: doc.category,
action: doc.action,
outcome: doc.outcome,
severity: doc.severity,
actor: {
type: doc.actor.type,
name: doc.actor.name,
...(doc.actor.id != null ? { id: doc.actor.id } : {}),
},
target: {
type: doc.target.type,
...(doc.target.id != null ? { id: doc.target.id } : {}),
...(doc.target.name != null ? { name: doc.target.name } : {}),
},
integrity: { seq: doc.seq, hash: doc.hash, prevHash: doc.prevHash },
timestamp: doc.createdAt.toISOString(),
};
if (doc.metadata != null) entry.metadata = doc.metadata;
const context = normalizeContext(doc.context);
if (context) entry.context = context;
if (doc.tenantId != null) entry.tenantId = doc.tenantId;
return entry;
}
function clampLimit(limit?: number): number {
if (!limit || Number.isNaN(limit) || limit < 1) return DEFAULT_LIMIT;
return Math.min(Math.floor(limit), MAX_AUDIT_LOG_LIMIT);
}
/**
* Builds the Mongo `find` filter for audit-log queries. Tenant scoping is
* always applied first and uses the compound `{ tenantId, createdAt, _id }`
* index. The substring regex filters below (`actorName`, `targetName`,
* `capability`, `search`) are case-insensitive and therefore cannot use a
* standard B-tree index: within the tenant slice they degrade to a partition
* scan. For deployments where the audit log grows past hundreds of thousands
* of entries per tenant, consider a text index or storing lowercased shadow
* fields. Today's primary listing combines `tenantId` with `createdAt`
* pagination, so the scan stays bounded by the user-chosen date window.
* Builds the Mongo `find` filter for audit-log queries. Chain scoping is always
* applied first and uses the compound `{ chainKey, ... }` indexes. The substring
* regex filters (`actor.name`, `target.name`, `metadata.capability`, `search`)
* are case-insensitive and cannot use a B-tree index: within the chain slice
* they degrade to a partition scan. The primary listing combines `chainKey` with
* `seq`/`createdAt` pagination, so the scan stays bounded by the date window.
*/
function buildFilter(
tenantId: string | undefined,
filters: AuditLogFilters,
): FilterQuery<IAuditLog> {
const query: FilterQuery<IAuditLog> = { ...tenantFilter(tenantId) };
function buildFilter(chainKey: string, filters: AuditLogFilters): FilterQuery<IAuditLog> {
const query: FilterQuery<IAuditLog> = { chainKey };
if (filters.category && filters.category.length > 0) {
query.category = oneOrIn(filters.category);
}
if (filters.action && filters.action.length > 0) {
query.action = filters.action.length === 1 ? filters.action[0] : { $in: filters.action };
query.action = oneOrIn(filters.action);
}
if (filters.outcome && filters.outcome.length > 0) {
query.outcome = oneOrIn(filters.outcome);
}
if (filters.severity && filters.severity.length > 0) {
query.severity = oneOrIn(filters.severity);
}
if (filters.actorType) {
query['actor.type'] = filters.actorType;
}
if (filters.actorQuery) {
query.actorName = { $regex: escapeRegex(filters.actorQuery), $options: 'i' };
query['actor.name'] = regexFilter(filters.actorQuery);
}
if (filters.targetPrincipalType) {
query.targetPrincipalType = filters.targetPrincipalType;
if (filters.targetType) {
query['target.type'] = filters.targetType;
}
if (filters.targetQuery) {
query.targetName = { $regex: escapeRegex(filters.targetQuery), $options: 'i' };
query['target.name'] = regexFilter(filters.targetQuery);
}
if (filters.capability) {
query.capability = { $regex: escapeRegex(filters.capability), $options: 'i' };
query['metadata.capability'] = regexFilter(filters.capability);
}
if (filters.from || filters.to) {
const createdAt: { $gte?: Date; $lte?: Date } = {};
@ -100,117 +251,180 @@ function buildFilter(
query.createdAt = createdAt;
}
if (filters.search && filters.search.length > 0) {
const trimmed = filters.search.slice(0, MAX_SEARCH_LENGTH);
const safe = escapeRegex(trimmed);
const safe = regexFilter(filters.search.slice(0, MAX_SEARCH_LENGTH));
query.$or = [
{ actorName: { $regex: safe, $options: 'i' } },
{ targetName: { $regex: safe, $options: 'i' } },
{ capability: { $regex: safe, $options: 'i' } },
{ 'actor.name': safe },
{ 'target.name': safe },
{ action: safe },
{ 'metadata.capability': safe },
];
}
return query;
}
function clampLimit(limit?: number): number {
if (!limit || Number.isNaN(limit) || limit < 1) return DEFAULT_LIMIT;
return Math.min(Math.floor(limit), MAX_AUDIT_LOG_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>;
/**
* Match the read-side `tenantFilter` convention: blank or whitespace-only
* tenant IDs are platform-level scope and the field must be omitted
* entirely (so the row matches `{ tenantId: { $exists: false } }` queries
* and satisfies the non-empty-string validator on the schema).
*/
const normalizedTenantId =
typeof input.tenantId === 'string' && input.tenantId.trim().length > 0
? input.tenantId
: undefined;
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,
...(normalizedTenantId !== undefined && { tenantId: normalizedTenantId }),
});
return doc;
} catch (err) {
/**
* Audit emission must never block a privileged operation, so a failed
* write returns null instead of throwing. The structured payload below
* is the only forensic trail when downstream alerting flags the
* `failed to record audit entry` message.
*/
logger.error('[auditLog] failed to record audit entry', {
action: input.action,
capability: input.capability,
tenantId: input.tenantId,
actorId:
typeof input.actorId === 'string' ? input.actorId : (input.actorId?.toString?.() ?? null),
targetPrincipalType: input.targetPrincipalType,
targetPrincipalId:
typeof input.targetPrincipalId === 'string'
? input.targetPrincipalId
: (input.targetPrincipalId?.toString?.() ?? null),
err,
});
return null;
function model(): Model<IAuditLog> {
return mongoose.models.AuditLog as Model<IAuditLog>;
}
async function recordAuditEntry(
input: RecordAuditEntryInput,
options?: RecordAuditEntryOptions,
): Promise<IAuditLog | null> {
const AuditLog = model();
const tenantId = normalizeTenantId(input.tenantId);
const chainKey = resolveChainKey(input.tenantId);
const category = input.category ?? AUDIT_ACTION_CATEGORY[input.action];
const outcome = input.outcome ?? 'success';
const severity =
input.severity ?? (outcome === 'failure' || outcome === 'denied' ? 'warning' : 'info');
const actor = {
type: input.actor.type,
name: input.actor.name,
...(input.actor.id != null ? { id: idToString(input.actor.id) } : {}),
};
const target = {
type: input.target.type,
...(input.target.id != null ? { id: idToString(input.target.id) } : {}),
...(input.target.name != null ? { name: input.target.name } : {}),
};
const metadata = normalizeMetadata(input.metadata);
const context = normalizeContext(input.context);
for (let attempt = 0; attempt < MAX_APPEND_RETRIES; attempt++) {
try {
const last = await AuditLog.findOne({ chainKey })
.sort({ seq: -1 })
.select('seq hash')
.lean<{ seq: number; hash: string }>();
const prevSeq = last?.seq ?? 0;
const prevHash = last?.hash ?? GENESIS_HASH;
const seq = prevSeq + 1;
const createdAt = new Date();
const hash = computeEntryHash({
schemaVersion: AUDIT_SCHEMA_VERSION,
category,
action: input.action,
outcome,
severity,
actor,
target,
metadata,
context,
tenantId,
chainKey,
seq,
prevHash,
createdAt,
});
const doc = await AuditLog.create({
schemaVersion: AUDIT_SCHEMA_VERSION,
category,
action: input.action,
outcome,
severity,
actor,
target,
...(metadata !== undefined && { metadata }),
...(context !== undefined && { context }),
...(tenantId !== undefined && { tenantId }),
chainKey,
seq,
prevHash,
hash,
createdAt,
});
return doc;
} catch (err) {
if (isDuplicateKeyError(err) && attempt < MAX_APPEND_RETRIES - 1) {
continue;
}
/**
* Fail-open by default: a failed write must never block the privileged
* operation, so it returns null and leaves a structured forensic trail.
* Callers that require a durable record pass `failClosed` to surface the
* failure instead.
*/
logger.error('[auditLog] failed to record audit entry', {
action: input.action,
category,
outcome,
chainKey,
tenantId,
actorId: actor.id ?? null,
actorType: actor.type,
targetType: target.type,
targetId: target.id ?? null,
err,
});
if (options?.failClosed) {
throw err instanceof Error ? err : new Error('Failed to record audit entry');
}
return null;
}
}
return null;
}
async function listAuditLogPage(
tenantId: string | undefined,
filters: AuditLogFilters,
): Promise<AuditLogPage> {
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
const AuditLog = model();
const chainKey = resolveChainKey(tenantId);
const limit = clampLimit(filters.limit);
const offset = filters.offset && filters.offset > 0 ? Math.floor(filters.offset) : 0;
const query = buildFilter(tenantId, filters);
const base = buildFilter(chainKey, filters);
const [rows, total] = await Promise.all([
AuditLog.find(query)
.sort({ createdAt: -1, _id: -1 })
.skip(offset)
.limit(limit)
.lean<IAuditLog[]>(),
AuditLog.countDocuments(query),
const cursorSeq =
typeof filters.cursor === 'number' && Number.isFinite(filters.cursor)
? filters.cursor
: undefined;
const paged: FilterQuery<IAuditLog> =
cursorSeq !== undefined ? { ...base, seq: { $lt: cursorSeq } } : base;
const offset =
cursorSeq === undefined && filters.offset && filters.offset > 0
? Math.floor(filters.offset)
: 0;
let pageQuery = AuditLog.find(paged)
.sort({ seq: -1 })
.limit(limit + 1);
if (offset > 0) pageQuery = pageQuery.skip(offset);
const [rowsPlusOne, total] = await Promise.all([
pageQuery.lean<IAuditLog[]>(),
AuditLog.countDocuments(base),
]);
return {
entries: rows.map(toWire),
total,
};
const hasMore = rowsPlusOne.length > limit;
const rows = hasMore ? rowsPlusOne.slice(0, limit) : rowsPlusOne;
const nextCursor = hasMore && rows.length > 0 ? rows[rows.length - 1].seq : null;
return { entries: rows.map(toWire), total, nextCursor };
}
async function findAuditLogEntry(
tenantId: string | undefined,
id: string,
): Promise<AdminAuditLogEntry | null> {
if (!/^[a-fA-F0-9]{24}$/.test(id)) return null;
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
const query: FilterQuery<IAuditLog> = { _id: id, ...tenantFilter(tenantId) };
if (!OBJECT_ID_RE.test(id)) return null;
const AuditLog = model();
const query: FilterQuery<IAuditLog> = { _id: id, chainKey: resolveChainKey(tenantId) };
const doc = await AuditLog.findOne(query).lean<IAuditLog>();
return doc ? toWire(doc) : null;
}
async function streamAuditLogEntries(
tenantId: string | undefined,
filters: Omit<AuditLogFilters, 'offset' | 'limit'>,
filters: Omit<AuditLogFilters, 'offset' | 'limit' | 'cursor'>,
onEntry: (entry: AdminAuditLogEntry) => void | Promise<void>,
options?: { isCancelled?: () => boolean; maxRows?: number },
): Promise<number> {
const AuditLog = mongoose.models.AuditLog as Model<IAuditLog>;
const query = buildFilter(tenantId, filters);
const AuditLog = model();
const query = buildFilter(resolveChainKey(tenantId), filters);
const cursor = AuditLog.find(query)
.sort({ createdAt: -1, _id: -1 })
.sort({ seq: -1 })
.lean<IAuditLog[]>()
.cursor({ batchSize: 500 });
@ -236,10 +450,122 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi
return count;
}
async function verifyAuditChain(tenantId: string | undefined): Promise<AuditChainVerification> {
const AuditLog = model();
const chainKey = resolveChainKey(tenantId);
const cursor = AuditLog.find({ chainKey })
.sort({ seq: 1 })
.lean<IAuditLog[]>()
.cursor({ batchSize: 500 });
let prevHash = GENESIS_HASH;
let expectedSeq: number | null = null;
let firstSeq: number | null = null;
let lastSeq = 0;
let checked = 0;
try {
for await (const doc of cursor) {
if (firstSeq === null) {
firstSeq = doc.seq;
expectedSeq = doc.seq;
/** Genesis must link to GENESIS_HASH; a purged prefix (firstSeq > 1)
* makes the earliest remaining entry's prevHash a trusted checkpoint. */
prevHash = doc.seq === 1 ? GENESIS_HASH : doc.prevHash;
}
if (doc.seq !== expectedSeq) {
return {
ok: false,
chainKey,
checked,
brokenAt: expectedSeq ?? doc.seq,
reason: `sequence gap: expected ${expectedSeq}, found ${doc.seq}`,
range: { firstSeq, lastSeq },
};
}
if (doc.prevHash !== prevHash) {
return {
ok: false,
chainKey,
checked,
brokenAt: doc.seq,
reason: 'broken hash link',
range: { firstSeq, lastSeq },
};
}
if (computeEntryHash(doc) !== doc.hash) {
return {
ok: false,
chainKey,
checked,
brokenAt: doc.seq,
reason: 'hash mismatch',
range: { firstSeq, lastSeq },
};
}
prevHash = doc.hash;
lastSeq = doc.seq;
expectedSeq = doc.seq + 1;
checked++;
}
} finally {
await cursor.close().catch(() => undefined);
}
return {
ok: true,
chainKey,
checked,
range: firstSeq !== null ? { firstSeq, lastSeq } : undefined,
};
}
async function purgeAuditLogEntries(
tenantId: string | undefined,
options: PurgeAuditLogOptions,
): Promise<PurgeAuditLogResult> {
if (!options.confirm) return { deletedCount: 0 };
if (!(options.before instanceof Date) || Number.isNaN(options.before.getTime())) {
throw new Error('purgeAuditLogEntries: `before` must be a valid Date');
}
const AuditLog = model();
const chainKey = resolveChainKey(tenantId);
/**
* Retention purge is the one privileged path that removes audit rows. It
* intentionally uses the raw driver to bypass the append-only Mongoose hooks
* (the model blocks every delete). Tenant isolation is applied manually via
* `chainKey`. `createdAt` is monotonic with `seq`, so a date-bounded delete
* removes a contiguous prefix and leaves the chain verifiable from the new
* earliest entry's prevHash checkpoint.
*/
// eslint-disable-next-line no-restricted-syntax -- privileged retention purge; append-only hooks block deleteMany, tenant scoped via chainKey
const result = await AuditLog.collection.deleteMany({
chainKey,
createdAt: { $lt: options.before },
});
const newFirst = await AuditLog.findOne({ chainKey })
.sort({ seq: 1 })
.select('seq prevHash')
.lean<{ seq: number; prevHash: string }>();
logger.warn('[auditLog] retention purge executed', {
chainKey,
before: options.before.toISOString(),
deletedCount: result.deletedCount ?? 0,
});
return {
deletedCount: result.deletedCount ?? 0,
checkpoint: newFirst ? { seq: newFirst.seq, prevHash: newFirst.prevHash } : undefined,
};
}
return {
recordAuditEntry,
listAuditLogPage,
findAuditLogEntry,
streamAuditLogEntries,
verifyAuditChain,
purgeAuditLogEntries,
};
}

View file

@ -22,6 +22,7 @@ import { createAclEntryMethods, permissionBitSupersets, type AclEntryMethods } f
import { createSystemGrantMethods, type SystemGrantMethods } from './systemGrant';
import {
createAuditLogMethods,
AUDIT_SCHEMA_VERSION,
MAX_AUDIT_EXPORT_ROWS,
MAX_AUDIT_LOG_LIMIT,
type AuditLogMethods,
@ -107,7 +108,7 @@ export {
deriveStructuredFrontmatterFields,
inferSkillFileCategory,
};
export { MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT };
export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT };
export type AllMethods = UserMethods &
SessionMethods &

View file

@ -3,12 +3,12 @@ import type * as t from '~/types';
import auditLogSchema from '~/schema/auditLog';
/**
* AuditLog is an append-only compliance record of SystemGrant changes.
* AuditLog is an append-only, hash-chained compliance record.
*
* 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 } }`.
* every query is scoped by `chainKey` (the JWT-resolved tenantId, or the
* platform sentinel for admin operations outside any tenant), which is also the
* serialization key for the per-tenant hash chain.
*/
export function createAuditLogModel(mongoose: typeof import('mongoose')): Model<t.IAuditLog> {
return mongoose.models.AuditLog || mongoose.model<t.IAuditLog>('AuditLog', auditLogSchema);

View file

@ -1,79 +1,109 @@
import { Schema } from 'mongoose';
import { PrincipalType } from 'librechat-data-provider';
import type { IAuditLog } from '~/types';
import { AUDIT_ACTIONS } from '~/types/admin';
import {
AUDIT_ACTIONS,
AUDIT_ACTOR_TYPES,
AUDIT_CATEGORIES,
AUDIT_OUTCOMES,
AUDIT_SEVERITIES,
} from '~/types/admin';
/** Sentinel `chainKey` for platform-level (non-tenant) audit entries. */
export const PLATFORM_CHAIN_KEY = '__platform__';
/** Genesis link: the `prevHash` of the first entry in any chain. */
export const GENESIS_HASH: string = '0'.repeat(64);
const actorSchema = new Schema(
{
type: { type: String, enum: [...AUDIT_ACTOR_TYPES], required: true, immutable: true },
id: { type: String, required: false, immutable: true },
name: { type: String, required: true, immutable: true },
},
{ _id: false },
);
const targetSchema = new Schema(
{
type: { type: String, required: true, immutable: true },
id: { type: String, required: false, immutable: true },
name: { type: String, required: false, immutable: true },
},
{ _id: false },
);
const contextSchema = new Schema(
{
requestId: { type: String, required: false, immutable: true },
ip: { type: String, required: false, immutable: true },
userAgent: { type: String, required: false, immutable: true },
sessionId: { type: String, required: false, immutable: true },
},
{ _id: false },
);
/**
* Append-only by schema contract: every field is `immutable`, every
* update-style operation is short-circuited in the pre-hooks below, and
* `updatedAt` is intentionally not maintained a mutable timestamp would
* imply mutation is allowed.
* update-style operation is short-circuited in the pre-hooks below, and there
* is no `updatedAt` a mutable timestamp would imply mutation is allowed.
* `createdAt` is set explicitly by the writer (not by `timestamps`) so it is
* covered by the per-entry hash. Tamper-evidence beyond these app-layer guards
* comes from the hash chain (`prevHash`/`hash`/`seq`) maintained in
* `~/methods/auditLog`.
*/
const auditLogSchema: Schema<IAuditLog> = new Schema<IAuditLog>(
{
action: {
type: String,
enum: [...AUDIT_ACTIONS],
required: true,
immutable: true,
},
actorId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
immutable: true,
},
actorName: {
type: String,
required: true,
immutable: true,
},
targetPrincipalType: {
type: String,
enum: Object.values(PrincipalType),
required: true,
immutable: 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,
immutable: true,
},
targetName: {
type: String,
required: true,
immutable: true,
},
capability: {
type: String,
required: true,
immutable: 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,
immutable: true,
validate: {
validator: (v: unknown) => typeof v === 'string' && v.trim().length > 0,
message:
'tenantId must be a non-empty string or omitted entirely — never null, empty, or a non-string value',
},
const auditLogSchema: Schema<IAuditLog> = new Schema<IAuditLog>({
schemaVersion: { type: Number, required: true, immutable: true },
category: {
type: String,
enum: [...AUDIT_CATEGORIES],
required: true,
immutable: true,
},
action: {
type: String,
enum: [...AUDIT_ACTIONS],
required: true,
immutable: true,
},
outcome: {
type: String,
enum: [...AUDIT_OUTCOMES],
required: true,
immutable: true,
},
severity: {
type: String,
enum: [...AUDIT_SEVERITIES],
required: true,
immutable: true,
},
actor: { type: actorSchema, required: true, immutable: true },
target: { type: targetSchema, required: true, immutable: true },
/** Event-specific payload (e.g. `{ capability }` for grants). Open by design;
* never store raw prompts, secrets, or full tool outputs here. */
metadata: { type: Schema.Types.Mixed, required: false, immutable: true },
context: { type: contextSchema, required: false, immutable: true },
/**
* Platform-level entries MUST omit this field entirely never set it to null.
* Tenant-scoped queries derive `chainKey` from `tenantId`; absence here maps
* to the platform chain.
*/
tenantId: {
type: String,
required: false,
immutable: true,
validate: {
validator: (v: unknown) => typeof v === 'string' && v.trim().length > 0,
message:
'tenantId must be a non-empty string or omitted entirely — never null, empty, or a non-string value',
},
},
{ timestamps: { createdAt: true, updatedAt: false } },
);
chainKey: { type: String, required: true, immutable: true },
seq: { type: Number, required: true, immutable: true },
prevHash: { type: String, required: true, immutable: true },
hash: { type: String, required: true, immutable: true },
createdAt: { type: Date, required: true, immutable: true },
});
const APPEND_ONLY_MESSAGE = 'AuditLog is append-only — updates and deletes are forbidden';
@ -92,11 +122,10 @@ auditLogSchema.pre(['deleteOne', 'deleteMany', 'findOneAndDelete'], function (ne
/**
* Mongoose registers `deleteOne` / `updateOne` pre-hooks as query middleware
* by default, which leaves `Document.prototype.deleteOne()` and
* `Document.prototype.updateOne()` as escape hatches around the append-only
* contract: a caller that has already loaded a doc could invoke either method
* on the instance and bypass the query-level hooks above. The explicit
* `{ document: true, query: false }` registrations below close both holes.
* by default, leaving `Document.prototype.deleteOne()` and
* `Document.prototype.updateOne()` as escape hatches: a caller holding a loaded
* doc could invoke either on the instance and bypass the query-level hooks.
* The explicit `{ document: true, query: false }` registrations close both.
*/
auditLogSchema.pre('deleteOne', { document: true, query: false }, function (next) {
next(new Error(APPEND_ONLY_MESSAGE));
@ -105,10 +134,7 @@ auditLogSchema.pre('updateOne', { document: true, query: false }, function (next
next(new Error(APPEND_ONLY_MESSAGE));
});
/**
* Document-level `save` is allowed for new docs only. A second save on an
* existing document would mutate it, so reject that case explicitly.
*/
/** Document-level `save` is allowed for new docs only; a second save mutates. */
auditLogSchema.pre('save', function (next) {
if (!this.isNew) {
next(new Error(APPEND_ONLY_MESSAGE));
@ -118,13 +144,17 @@ auditLogSchema.pre('save', function (next) {
});
/**
* Primary listing: tenant + newest-first sort with `_id` as a deterministic
* tiebreak for offset pagination so adjacent pages never duplicate or skip rows
* when two entries share a `createdAt` timestamp.
* Unique per-chain sequence. This is both the keyset-pagination key and the
* integrity backstop: concurrent appends race to claim the next `seq`, one wins,
* and the losers retry so the chain can never fork.
*/
auditLogSchema.index({ tenantId: 1, createdAt: -1, _id: -1 });
auditLogSchema.index({ chainKey: 1, seq: 1 }, { unique: true });
/** Target facet: filter by who was affected. */
auditLogSchema.index({ tenantId: 1, targetPrincipalType: 1, targetPrincipalId: 1, createdAt: -1 });
/** Primary listing: newest-first within a chain, bounded by a date window. */
auditLogSchema.index({ chainKey: 1, createdAt: -1, seq: -1 });
/** Faceted reads. */
auditLogSchema.index({ chainKey: 1, category: 1, createdAt: -1 });
auditLogSchema.index({ chainKey: 1, 'target.type': 1, 'target.id': 1, createdAt: -1 });
export default auditLogSchema;

View file

@ -65,14 +65,110 @@ export type AdminConfigDeleteResponse = {
success: boolean;
};
/** Single source of truth for the audit-action enum. Both the Mongoose schema
* enum and the HTTP handler's whitelist consume this constant so they cannot
* drift apart. */
export const AUDIT_ACTIONS = ['grant_assigned', 'grant_removed'] as const;
/* ── Audit log taxonomy ─────────────────────────────────────────────── */
/** Audit action types for grant changes. */
/**
* High-level domains an audit entry can belong to. The audit log is a
* general-purpose, append-only compliance record; new domains (agent runs,
* tool/MCP calls, config and permission changes, approvals) are added here as
* the surface grows, without reshaping the record.
*/
export const AUDIT_CATEGORIES = [
'grant',
'agent_run',
'tool_call',
'mcp',
'config',
'permission',
'auth',
'approval',
] as const;
export type AuditCategory = (typeof AUDIT_CATEGORIES)[number];
/**
* Single source of truth for the audit-action enum. Actions are namespaced
* `<category>.<verb>` so the registry stays readable as it grows and every
* action maps unambiguously to a category. The Mongoose schema enum and the
* HTTP handler's whitelist both consume this constant so they cannot drift.
*/
export const AUDIT_ACTIONS = ['grant.assigned', 'grant.removed'] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
/** Maps each action to its category so writers never pass both. */
export const AUDIT_ACTION_CATEGORY: Record<AuditAction, AuditCategory> = {
'grant.assigned': 'grant',
'grant.removed': 'grant',
};
/** Result of the audited operation. Kept first-class instead of being encoded
* into the action so `allowed` vs `denied` vs `failed` is queryable. */
export const AUDIT_OUTCOMES = ['success', 'failure', 'denied', 'pending'] as const;
export type AuditOutcome = (typeof AUDIT_OUTCOMES)[number];
/** Coarse severity for SIEM routing and alerting. */
export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const;
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number];
/**
* Who initiated the action. Non-human actors are first-class: a scheduled job,
* an agent acting autonomously, an internal service, or a webhook are all
* representable without forcing a `User` id.
*/
export const AUDIT_ACTOR_TYPES = [
'user',
'system',
'agent',
'service',
'schedule',
'webhook',
'api',
] as const;
export type AuditActorType = (typeof AUDIT_ACTOR_TYPES)[number];
/** Primitive metadata values; event-specific payload is a flat string-keyed map
* (e.g. `{ capability }` for grants, `{ runId, triggerType }` for agent runs). */
export type AuditMetadataValue = string | number | boolean | null;
export type AuditMetadata = Record<string, AuditMetadataValue>;
/** Denormalized actor identity captured at write time. */
export type AuditActor = {
type: AuditActorType;
/** Stable id (user id, service-account id, agent id); absent for anonymous
* system events. */
id?: string;
/** Display name captured at write time so the record stays readable after the
* underlying principal is renamed or deleted. */
name: string;
};
/** Generic target of the action not principal-locked, so it can describe a
* role, agent, MCP server, config section, etc. */
export type AuditTarget = {
type: string;
id?: string;
name?: string;
};
/** Request context for forensic joins and SIEM correlation. */
export type AuditContext = {
/** Correlation id (`x-request-id` / `x-correlation-id`). */
requestId?: string;
ip?: string;
userAgent?: string;
sessionId?: string;
};
/** Per-entry tamper-evidence surfaced to readers. The full chain is verifiable
* via the verify endpoint. */
export type AuditIntegrity = {
/** Monotonic per-chain sequence number (1-based). */
seq: number;
/** SHA-256 of this entry's canonical content linked to `prevHash`. */
hash: string;
/** Hash of the previous entry in the chain (genesis links to a zero hash). */
prevHash: string;
};
/** SystemGrant document as returned by the admin API. */
export type AdminSystemGrant = {
id: string;
@ -84,16 +180,22 @@ export type AdminSystemGrant = {
expiresAt?: string;
};
/** Audit log entry for grant changes as returned by the admin API. */
/** Audit log entry as returned by the admin API. */
export type AdminAuditLogEntry = {
id: string;
schemaVersion: number;
category: AuditCategory;
action: AuditAction;
actorId: string;
actorName: string;
targetPrincipalType: PrincipalType;
targetPrincipalId: string;
targetName: string;
capability: string;
outcome: AuditOutcome;
severity: AuditSeverity;
actor: AuditActor;
target: AuditTarget;
metadata?: AuditMetadata;
context?: AuditContext;
/** Absent = platform-operator entry; present = tenant-scoped entry. */
tenantId?: string;
integrity: AuditIntegrity;
/** `createdAt` as an ISO 8601 string. */
timestamp: string;
};

View file

@ -1,28 +1,50 @@
import type { PrincipalType } from 'librechat-data-provider';
import type { Document, Types } from 'mongoose';
import type { AdminAuditLogEntry, AuditAction } from './admin';
import type {
AdminAuditLogEntry,
AuditAction,
AuditActor,
AuditActorType,
AuditCategory,
AuditContext,
AuditIntegrity,
AuditMetadata,
AuditOutcome,
AuditSeverity,
AuditTarget,
} from './admin';
/**
* AuditLog is an append-only collection: no `updatedAt`, no mutations.
* See `~/schema/auditLog` for the enforcement (immutable fields plus
* pre-update / pre-delete / pre-save hooks).
*
* `createdAt` is always set by Mongoose at insert time, so this type
* intentionally declares it required. The `IAuditLog` intersection adds
* the document identity (`_id`).
* AuditLog is an append-only, hash-chained compliance record. Enforcement lives
* in `~/schema/auditLog` (immutable fields, pre-update/delete/save hooks) and in
* `~/methods/auditLog` (per-chain hash linking + a unique `{ chainKey, seq }`
* index that serializes concurrent appends). `createdAt` is set explicitly by
* the writer so it is covered by the entry hash.
*/
export type AuditLog = {
/** Record-format version, so future migrations can interpret older rows. */
schemaVersion: number;
category: AuditCategory;
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 */
outcome: AuditOutcome;
severity: AuditSeverity;
actor: AuditActor;
target: AuditTarget;
metadata?: AuditMetadata;
context?: AuditContext;
/** Absent = platform-level entry; present = tenant-scoped entry. */
tenantId?: string;
/** Mongoose auto-managed via `timestamps: { createdAt: true, updatedAt: false }` */
/**
* Always present; equals `tenantId` or the platform sentinel. The hash chain
* and keyset pagination are scoped to this key, and `{ chainKey, seq }` is the
* unique index that serializes appends.
*/
chainKey: string;
/** Monotonic per-chain sequence number (1-based). */
seq: number;
/** Hash of the previous entry in the chain; genesis links to the zero hash. */
prevHash: string;
/** SHA-256 over this entry's canonical content (including `seq` and `prevHash`). */
hash: string;
createdAt: Date;
};
@ -31,33 +53,120 @@ export type IAuditLog = AuditLog &
_id: Types.ObjectId;
};
/** Actor as accepted by writers; `id` may be an ObjectId for convenience. */
export interface AuditActorInput {
type: AuditActorType;
id?: string | Types.ObjectId;
name: string;
}
/** Target as accepted by writers; `id` may be an ObjectId for convenience. */
export interface AuditTargetInput {
type: string;
id?: string | Types.ObjectId;
name?: string;
}
export interface RecordAuditEntryInput {
action: AuditAction;
actorId: string | Types.ObjectId;
actorName: string;
targetPrincipalType: PrincipalType;
targetPrincipalId: string | Types.ObjectId;
targetName: string;
capability: string;
/** Derived from `action` when omitted. */
category?: AuditCategory;
/** Defaults to `'success'`. */
outcome?: AuditOutcome;
/** Defaults to `'warning'` for `failure`/`denied`, else `'info'`. */
severity?: AuditSeverity;
actor: AuditActorInput;
target: AuditTargetInput;
metadata?: AuditMetadata;
context?: AuditContext;
tenantId?: string;
}
/** Options that shape a single audit write. */
export interface RecordAuditEntryOptions {
/**
* When true, a failed write throws instead of resolving to `null`. Callers
* that must not proceed without a durable audit record opt in here; the
* default is fail-open so audit emission never blocks a privileged operation.
*/
failClosed?: boolean;
}
export interface AuditLogFilters {
search?: string;
category?: AuditCategory[];
action?: AuditAction[];
outcome?: AuditOutcome[];
severity?: AuditSeverity[];
/** Exact match on `actor.type`. */
actorType?: AuditActorType;
/** Case-insensitive substring match against the denormalized `actor.name`. */
actorQuery?: string;
/** Exact match on `target.type`. */
targetType?: string;
/** Case-insensitive substring match against the denormalized `target.name`. */
targetQuery?: string;
/** Case-insensitive substring match against `metadata.capability`. */
capability?: string;
from?: Date;
to?: Date;
/** Case-insensitive substring match against the denormalized `actorName`. */
actorQuery?: string;
targetPrincipalType?: PrincipalType;
/** Case-insensitive substring match against the denormalized `targetName`. */
targetQuery?: string;
capability?: string;
/** Offset pagination (legacy / random-access). Prefer `cursor`. */
offset?: number;
limit?: number;
/**
* Keyset cursor: the `seq` of the last entry from the previous page. Results
* are newest-first, so the next page is `seq < cursor`. Stable under
* concurrent appends, unlike `offset`.
*/
cursor?: number;
}
export interface AuditLogPage {
entries: AdminAuditLogEntry[];
total: number;
/** Pass as `cursor` to fetch the next page; `null` when the page is the last. */
nextCursor: number | null;
}
/** Outcome of verifying a chain's tamper-evidence. */
export interface AuditChainVerification {
ok: boolean;
chainKey: string;
/** Number of entries inspected. */
checked: number;
/** First `seq` where the chain broke (gap, broken link, or hash mismatch). */
brokenAt?: number;
reason?: string;
/** Earliest/latest `seq` present. `firstSeq > 1` indicates a purged prefix. */
range?: { firstSeq: number; lastSeq: number };
}
export interface PurgeAuditLogOptions {
/** Delete entries strictly older than this instant (a contiguous prefix). */
before: Date;
/** Required safety latch; the purge is a no-op unless explicitly confirmed. */
confirm: boolean;
}
export interface PurgeAuditLogResult {
deletedCount: number;
/** The new earliest remaining entry, which becomes the trusted checkpoint the
* verifier chains forward from. Absent when the chain is now empty. */
checkpoint?: { seq: number; prevHash: string };
}
/** Re-exported so consumers can import the entry shape and its supporting types
* from one module. */
export type {
AdminAuditLogEntry,
AuditAction,
AuditActor,
AuditActorType,
AuditCategory,
AuditContext,
AuditIntegrity,
AuditMetadata,
AuditOutcome,
AuditSeverity,
AuditTarget,
};