mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 00:33:40 +00:00
🛡️ 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:
parent
1ca6877caa
commit
bc8cef88ab
15 changed files with 1538 additions and 960 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue