diff --git a/api/server/routes/admin/audit.js b/api/server/routes/admin/audit.js index 588271463b..7332f8296a 100644 --- a/api/server/routes/admin/audit.js +++ b/api/server/routes/admin/audit.js @@ -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; diff --git a/api/server/routes/admin/grants.js b/api/server/routes/admin/grants.js index baa1325d9f..f48c3ddd60 100644 --- a/api/server/routes/admin/grants.js +++ b/api/server/routes/admin/grants.js @@ -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); diff --git a/packages/api/src/admin/auditLog.spec.ts b/packages/api/src/admin/auditLog.spec.ts index 5c39a233a6..1dc97d1996 100644 --- a/packages/api/src/admin/auditLog.spec.ts +++ b/packages/api/src/admin/auditLog.spec.ts @@ -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 { 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 { + return { entries: [], total: 0, nextCursor: null, ...overrides }; +} + +function mockVerification(overrides: Partial = {}): AuditChainVerification { + return { + ok: true, + chainKey: 'tenant-a', + checked: 3, + range: { firstSeq: 1, lastSeq: 3 }, + ...overrides, + }; +} + function createReqRes( overrides: { params?: Record; @@ -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 { return { - listAuditLogPage: jest - .fn, unknown[]>() - .mockResolvedValue({ entries: [], total: 0 }), + listAuditLogPage: jest.fn, unknown[]>().mockResolvedValue(mockPage()), findAuditLogEntry: jest .fn, unknown[]>() .mockResolvedValue(null), streamAuditLogEntries: jest.fn, unknown[]>().mockResolvedValue(0), + verifyAuditChain: jest + .fn, unknown[]>() + .mockResolvedValue(mockVerification()), ...overrides, }; } @@ -126,61 +146,44 @@ function createDeps(overrides: Partial = {}): 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, + 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, + 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)(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)(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)(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)(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(); }); }); }); diff --git a/packages/api/src/admin/auditLog.ts b/packages/api/src/admin/auditLog.ts index 9f4cde7515..27e3657ab5 100644 --- a/packages/api/src/admin/auditLog.ts +++ b/packages/api/src/admin/auditLog.ts @@ -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(AUDIT_ACTIONS); -const VALID_PRINCIPAL_TYPES = new Set(Object.values(PrincipalType)); +const VALID_ACTIONS = new Set(AUDIT_ACTIONS); +const VALID_CATEGORIES = new Set(AUDIT_CATEGORIES); +const VALID_OUTCOMES = new Set(AUDIT_OUTCOMES); +const VALID_SEVERITIES = new Set(AUDIT_SEVERITIES); +const VALID_ACTOR_TYPES = new Set(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; streamAuditLogEntries: ( tenantId: string | undefined, - filters: Omit, + filters: Omit, onEntry: (entry: AdminAuditLogEntry) => void | Promise, options?: { isCancelled?: () => boolean; maxRows?: number }, ) => Promise; + verifyAuditChain: (tenantId: string | undefined) => Promise; } 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 = { ok: true; value: T } | { ok: false; error: string }; + +/** Parses a repeatable enum filter (`?action=a&action=b`) against a whitelist. */ +function parseEnumArray( + raw: unknown, + valid: Set, + label: string, +): ParseResult { 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( + raw: unknown, + valid: Set, + label: string, +): ParseResult { + 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 { 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 { 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; +type ParsedFilters = Omit; 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 { 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(query.category, VALID_CATEGORIES, 'category'); + if (!category.ok) return { ok: false, error: category.error }; + const action = parseEnumArray(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(query.outcome, VALID_OUTCOMES, 'outcome'); + if (!outcome.ok) return { ok: false, error: outcome.error }; + const severity = parseEnumArray(query.severity, VALID_SEVERITIES, 'severity'); + if (!severity.ok) return { ok: false, error: severity.error }; + const actorType = parseEnum(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; getAuditLogEntry: (req: ServerRequest, res: Response) => Promise; + verifyAuditLog: (req: ServerRequest, res: Response) => Promise; exportAuditLogCsv: (req: ServerRequest, res: Response) => Promise; } { - 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 => { if (clientAborted) return Promise.resolve(); @@ -390,6 +404,7 @@ export function createAdminAuditLogHandlers(deps: AdminAuditLogDeps): { return { listAuditLog: listAuditLogHandler, getAuditLogEntry: getAuditLogEntryHandler, + verifyAuditLog: verifyAuditLogHandler, exportAuditLogCsv: exportAuditLogCsvHandler, }; } diff --git a/packages/api/src/admin/grants.spec.ts b/packages/api/src/admin/grants.spec.ts index 0399df98a1..b5624a2171 100644 --- a/packages/api/src/admin/grants.spec.ts +++ b/packages/api/src/admin/grants.spec.ts @@ -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); + }); }); }); diff --git a/packages/api/src/admin/grants.ts b/packages/api/src/admin/grants.ts index 1119096f8c..27e83e6ba5 100644 --- a/packages/api/src/admin/grants.ts +++ b/packages/api/src/admin/grants.ts @@ -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; - /** Optional audit emission. Failure is logged but does not roll back the grant. */ - recordAuditEntry?: (input: RecordAuditEntryInput) => Promise; + /** Optional audit emission. Failure is logged but does not roll back the grant + * unless `auditFailClosed` is set. */ + recordAuditEntry?: ( + input: RecordAuditEntryInput, + options?: RecordAuditEntryOptions, + ) => Promise; + /** + * 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 { 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 }); diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index a3c76db9a9..90f05b1fa4 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -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", diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index d7ba18a3da..e35c29e2e3 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -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'; diff --git a/packages/data-schemas/src/methods/auditLog.spec.ts b/packages/data-schemas/src/methods/auditLog.spec.ts index 97acf51af3..a16d5f5fe1 100644 --- a/packages/data-schemas/src/methods/auditLog.spec.ts +++ b/packages/data-schemas/src/methods/auditLog.spec.ts @@ -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; let methods: ReturnType; const actorObjectId = new Types.ObjectId(); -const targetObjectId = new Types.ObjectId(); function baseInput(over: Partial = {}): 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 = {}): Promise { + 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('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); }); }); }); diff --git a/packages/data-schemas/src/methods/auditLog.ts b/packages/data-schemas/src/methods/auditLog.ts index 1ac7d0cc16..4dc79bfb91 100644 --- a/packages/data-schemas/src/methods/auditLog.ts +++ b/packages/data-schemas/src/methods/auditLog.ts @@ -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; + recordAuditEntry: ( + input: RecordAuditEntryInput, + options?: RecordAuditEntryOptions, + ) => Promise; listAuditLogPage: ( tenantId: string | undefined, filters: AuditLogFilters, @@ -31,67 +48,201 @@ export interface AuditLogMethods { ) => Promise; streamAuditLogEntries: ( tenantId: string | undefined, - filters: Omit, + filters: Omit, onEntry: (entry: AdminAuditLogEntry) => void | Promise, options?: { isCancelled?: () => boolean; maxRows?: number }, ) => Promise; + verifyAuditChain: (tenantId: string | undefined) => Promise; + purgeAuditLogEntries: ( + tenantId: string | undefined, + options: PurgeAuditLogOptions, + ) => Promise; } 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 { - return typeof tenantId === 'string' && tenantId.trim().length > 0 - ? { tenantId } - : { tenantId: { $exists: false } }; +function oneOrIn(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; + 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 { - const query: FilterQuery = { ...tenantFilter(tenantId) }; +function buildFilter(chainKey: string, filters: AuditLogFilters): FilterQuery { + const query: FilterQuery = { 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 { - const AuditLog = mongoose.models.AuditLog as Model; - /** - * 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 { + return mongoose.models.AuditLog as Model; + } + + async function recordAuditEntry( + input: RecordAuditEntryInput, + options?: RecordAuditEntryOptions, + ): Promise { + 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 { - const AuditLog = mongoose.models.AuditLog as Model; + 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(), - AuditLog.countDocuments(query), + const cursorSeq = + typeof filters.cursor === 'number' && Number.isFinite(filters.cursor) + ? filters.cursor + : undefined; + const paged: FilterQuery = + 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(), + 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 { - if (!/^[a-fA-F0-9]{24}$/.test(id)) return null; - const AuditLog = mongoose.models.AuditLog as Model; - const query: FilterQuery = { _id: id, ...tenantFilter(tenantId) }; + if (!OBJECT_ID_RE.test(id)) return null; + const AuditLog = model(); + const query: FilterQuery = { _id: id, chainKey: resolveChainKey(tenantId) }; const doc = await AuditLog.findOne(query).lean(); return doc ? toWire(doc) : null; } async function streamAuditLogEntries( tenantId: string | undefined, - filters: Omit, + filters: Omit, onEntry: (entry: AdminAuditLogEntry) => void | Promise, options?: { isCancelled?: () => boolean; maxRows?: number }, ): Promise { - const AuditLog = mongoose.models.AuditLog as Model; - 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() .cursor({ batchSize: 500 }); @@ -236,10 +450,122 @@ export function createAuditLogMethods(mongoose: typeof import('mongoose')): Audi return count; } + async function verifyAuditChain(tenantId: string | undefined): Promise { + const AuditLog = model(); + const chainKey = resolveChainKey(tenantId); + const cursor = AuditLog.find({ chainKey }) + .sort({ seq: 1 }) + .lean() + .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 { + 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, }; } diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 6811cafb2b..b98dab3a36 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -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 & diff --git a/packages/data-schemas/src/models/auditLog.ts b/packages/data-schemas/src/models/auditLog.ts index 2c71fa2594..0efd65391d 100644 --- a/packages/data-schemas/src/models/auditLog.ts +++ b/packages/data-schemas/src/models/auditLog.ts @@ -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 { return mongoose.models.AuditLog || mongoose.model('AuditLog', auditLogSchema); diff --git a/packages/data-schemas/src/schema/auditLog.ts b/packages/data-schemas/src/schema/auditLog.ts index 3723b80810..5058824c53 100644 --- a/packages/data-schemas/src/schema/auditLog.ts +++ b/packages/data-schemas/src/schema/auditLog.ts @@ -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 = new Schema( - { - 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 = new Schema({ + 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; diff --git a/packages/data-schemas/src/types/admin.ts b/packages/data-schemas/src/types/admin.ts index 98f1841bfe..0fcbf450e7 100644 --- a/packages/data-schemas/src/types/admin.ts +++ b/packages/data-schemas/src/types/admin.ts @@ -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 + * `.` 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 = { + '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; + +/** 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; }; diff --git a/packages/data-schemas/src/types/auditLog.ts b/packages/data-schemas/src/types/auditLog.ts index 5eeb6c3e70..c8c77b32c5 100644 --- a/packages/data-schemas/src/types/auditLog.ts +++ b/packages/data-schemas/src/types/auditLog.ts @@ -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, +};