Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/backend/src/__tests__/auditLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/config/rateLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
33 changes: 22 additions & 11 deletions apps/backend/src/routes/auditLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,30 +20,39 @@ import {
export const auditLogsRouter: RouterType = Router();

auditLogsRouter.use(requireAuth);
auditLogsRouter.use(rateLimit('audit_logs'));

const VALID_ACTIONS = new Set<string>(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),
});
Expand Down Expand Up @@ -69,4 +80,4 @@ auditLogsRouter.get('/', async (req: AuthRequest, res) => {
} catch {
res.status(500).json({ error: 'Failed to read audit log' });
}
});
});
1 change: 1 addition & 0 deletions docs/security/rate-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading