From 83692f1b83b32f13a2017855e9bdbf67be15eeca Mon Sep 17 00:00:00 2001 From: kosisochukwu1234 Date: Mon, 26 Oct 2026 11:50:05 +0100 Subject: [PATCH] fix(backend): mount auditLogsRouter in app.ts, add rate limit bucket, and validate query schema --- apps/backend/src/__tests__/auditLog.test.ts | 11 +++++++ apps/backend/src/app.ts | 2 ++ apps/backend/src/config/rateLimits.ts | 5 ++++ apps/backend/src/routes/auditLogs.ts | 33 ++++++++++++++------- docs/security/rate-limits.md | 1 + 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/__tests__/auditLog.test.ts b/apps/backend/src/__tests__/auditLog.test.ts index 8c9a71d5..3c058dd9 100644 --- a/apps/backend/src/__tests__/auditLog.test.ts +++ b/apps/backend/src/__tests__/auditLog.test.ts @@ -45,6 +45,7 @@ const { actorFromRequest, } = await import('../services/auditLog.js'); const { auditLogsRouter } = await import('../routes/auditLogs.js'); +const { app: fullApp } = await import('../app.js'); function makeApp() { const app = express(); @@ -365,4 +366,14 @@ describe('AC3 — logs are queryable for an account', () => { expect(mockAuditFindMany).toHaveBeenCalledWith(expect.objectContaining({ limit: 201 })); }); + + it('serves GET /audit-logs when mounted in app', async () => { + mockAuditFindMany.mockResolvedValue([]); + + const res = await request(fullApp).get('/audit-logs'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('events'); + expect(mockAuditFindMany).toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6fcd9b5b..997389a8 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -19,6 +19,7 @@ import { userDevicesRouter } from './routes/userDevices.js'; import { mlsRouter } from './routes/mls.js'; import { localStorageRouter } from './routes/localStorage.js'; import { securityRouter } from './routes/security.js'; +import { auditLogsRouter } from './routes/auditLogs.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; import { registry } from './lib/metrics.js'; import { trustProxyHops, isOriginAllowed, allowedOrigins } from './lib/transportSecurity.js'; @@ -91,6 +92,7 @@ app.use('/push', pushRouter); app.use('/sync', syncRouter); app.use('/user-devices', userDevicesRouter); app.use('/security', securityRouter); +app.use('/audit-logs', auditLogsRouter); // #330 — dev/test only: serves the fs-backed object store so presigned URLs // issued locally are real, working URLs. Deliberately outside requireAuth diff --git a/apps/backend/src/config/rateLimits.ts b/apps/backend/src/config/rateLimits.ts index d3b33c2b..7b5839be 100644 --- a/apps/backend/src/config/rateLimits.ts +++ b/apps/backend/src/config/rateLimits.ts @@ -92,6 +92,11 @@ export const RATE_LIMIT_DEFAULTS = { windowSeconds: MINUTE, description: 'Web-push subscription registration', }, + audit_logs: { + limit: 30, + windowSeconds: MINUTE, + description: 'Security audit log queries', + }, // ── Socket events ────────────────────────────────────────────────────────── socket_default: { diff --git a/apps/backend/src/routes/auditLogs.ts b/apps/backend/src/routes/auditLogs.ts index 9dfebf38..ac176681 100644 --- a/apps/backend/src/routes/auditLogs.ts +++ b/apps/backend/src/routes/auditLogs.ts @@ -7,7 +7,9 @@ * first, cursor-paginated. */ import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { rateLimit } from '../middleware/rateLimit.js'; import { auditActionEnum, type AuditAction } from '../db/schema.js'; import { DEFAULT_AUDIT_PAGE_SIZE, @@ -18,30 +20,39 @@ import { export const auditLogsRouter: RouterType = Router(); auditLogsRouter.use(requireAuth); +auditLogsRouter.use(rateLimit('audit_logs')); -const VALID_ACTIONS = new Set(auditActionEnum.enumValues); +export const AuditLogsQuerySchema = z.object({ + action: z.enum(auditActionEnum.enumValues).optional(), + limit: z.coerce.number().int().positive().optional().default(DEFAULT_AUDIT_PAGE_SIZE), + cursor: z.string().min(1).optional(), +}); auditLogsRouter.get('/', async (req: AuthRequest, res) => { const userId = req.auth!.userId; - const rawAction = typeof req.query['action'] === 'string' ? req.query['action'] : undefined; - if (rawAction && !VALID_ACTIONS.has(rawAction)) { + const parsed = AuditLogsQuerySchema.safeParse(req.query); + if (!parsed.success) { + if (req.query['action'] && !auditActionEnum.enumValues.includes(req.query['action'] as any)) { + res.status(400).json({ + error: 'Unknown action filter', + allowed: auditActionEnum.enumValues, + }); + return; + } res.status(400).json({ - error: 'Unknown action filter', - allowed: auditActionEnum.enumValues, + error: 'Invalid query parameters', + details: parsed.error.issues, }); return; } - const rawLimit = Number.parseInt(req.query['limit'] as string, 10); - const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : DEFAULT_AUDIT_PAGE_SIZE; - - const cursor = typeof req.query['cursor'] === 'string' ? req.query['cursor'] : undefined; + const { action, limit, cursor } = parsed.data; try { const { events, nextCursor, hasMore } = await queryAuditLog({ userId, - ...(rawAction ? { action: rawAction as AuditAction } : {}), + ...(action ? { action: action as AuditAction } : {}), ...(cursor ? { cursor } : {}), limit: Math.min(limit, MAX_AUDIT_PAGE_SIZE), }); @@ -69,4 +80,4 @@ auditLogsRouter.get('/', async (req: AuthRequest, res) => { } catch { res.status(500).json({ error: 'Failed to read audit log' }); } -}); +}); \ No newline at end of file diff --git a/docs/security/rate-limits.md b/docs/security/rate-limits.md index 7ab72760..44a686c8 100644 --- a/docs/security/rate-limits.md +++ b/docs/security/rate-limits.md @@ -18,6 +18,7 @@ gateway node instead of being multiplied by the node count. | `upload_bytes_daily` | 2 GiB / day | Quota on upload **volume**, charged in bytes | | `file_download` | 120 / min | `GET /files/:fileId` | | `push_subscribe` | 10 / min | `POST /push/subscriptions` | +| `audit_logs` | 30 / min | `GET /audit-logs` | | `socket_default` | 10 / sec | Any socket event without its own bucket | | `socket_send_message` | 30 / 10 sec | `send_message`, `send_file_message` | | `socket_typing` | 20 / 5 sec | `typing_start`, `typing_stop` |