Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js';
import { notificationsRouter } from './routes/notifications.js';
import { auditRouter } from './routes/audit.js';
import { taxReportingRouter } from './routes/tax-reporting.js';
import { auditMiddleware } from './middleware/audit.js';
import { apiKeysRouter } from './routes/api-keys.js';
import { milestonesRouter } from './routes/milestones.js';

Expand Down Expand Up @@ -185,6 +186,7 @@ app.use(
);

app.use(requestIdMiddleware);
app.use(auditMiddleware());

// Trace ID middleware
app.use((req: Request, res: Response, next: NextFunction) => {
Expand Down Expand Up @@ -254,6 +256,8 @@ apiV1Router.use('/emails', emailRouter);
apiV1Router.use('/portfolio', portfolioRouter);
// Backup system
apiV1Router.use('/backup', backupRouter);
// Audit system
apiV1Router.use('/audit', auditRouter);
// IP allowlist management
apiV1Router.use('/ip-allowlist', ipAllowlistRouter);
// Push notifications
Expand Down
93 changes: 93 additions & 0 deletions backend/src/middleware/__tests__/audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { auditMiddleware } from '../audit.js';
import { auditService } from '../../services/auditService.js';

vi.mock('../../services/auditService.js', () => {
return {
auditService: {
logAction: vi.fn().mockResolvedValue({ id: 'mock-id' }),
},
};
});

describe('auditMiddleware', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('skips logAction if the path is in excludePaths', () => {
const req = {
path: '/health',
method: 'GET',
headers: {},
} as unknown as Request;

const res = {
on: vi.fn(),
} as unknown as Response;

const next = vi.fn();
const middleware = auditMiddleware();

middleware(req, res, next);

expect(next).toHaveBeenCalledOnce();
expect(res.on).not.toHaveBeenCalled();
});

it('hooks into res.on("finish") and logs actions for non-excluded paths', () => {
const req = {
path: '/api/v1/payments',
method: 'POST',
headers: {
'user-agent': 'test-agent',
'x-user-id': 'user-1',
},
body: { amount: 100, password: 'secret-password' },
query: { mode: 'live' },
params: { id: 'payment-1' },
ip: '127.0.0.1',
} as unknown as Request;

let finishCallback: () => void = () => {};
const res = {
statusCode: 201,
on: vi.fn((event: string, cb: () => void) => {
if (event === 'finish') {
finishCallback = cb;
}
}),
} as unknown as Response;

const next = vi.fn();
const middleware = auditMiddleware();

middleware(req, res, next);

expect(next).toHaveBeenCalledOnce();
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));

// Simulate finish event
finishCallback();

expect(auditService.logAction).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
action: 'POST /api/v1/payments',
resource: 'payments',
resourceId: 'payment-1',
ipAddress: '127.0.0.1',
userAgent: 'test-agent',
request: expect.objectContaining({
method: 'POST',
path: '/api/v1/payments',
body: expect.objectContaining({ amount: 100, password: 'secret-password' }),
}),
response: expect.objectContaining({
status: 201,
}),
})
);
});
});
79 changes: 79 additions & 0 deletions backend/src/middleware/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Request, Response, NextFunction } from 'express';
import { auditService } from '../services/auditService.js';

export interface AuditMiddlewareOptions {
excludePaths?: string[];
actionMapper?: (req: Request) => string;
resourceMapper?: (req: Request) => string;
}

/**
* Express middleware that records user and system operations to the tamper-evident audit log.
*/
export function auditMiddleware(options: AuditMiddlewareOptions = {}) {
const excludePaths = options.excludePaths || ['/health', '/metrics', '/api-docs'];

return (req: Request, res: Response, next: NextFunction): void => {
// Check if the path should be excluded from audit logging
const isExcluded = excludePaths.some((p) => req.path.startsWith(p));
if (isExcluded) {
next();
return;
}

const startTime = Date.now();

// Hook into response finish event to write the audit entry
res.on('finish', () => {
const durationMs = Date.now() - startTime;

// Determine the user identifier from authorization context
const userId =
(req as any).user?.id ||
req.headers['x-user-id'] ||
req.headers['x-api-key'] ||
'anonymous';

// Map action and resource
const action = options.actionMapper
? options.actionMapper(req)
: `${req.method} ${req.path}`;

const resource = options.resourceMapper
? options.resourceMapper(req)
: req.baseUrl || req.path.split('/')[2] || 'root';

// Capture request body (sanitization happens inside auditService.logAction)
const requestBody = req.body;

void auditService.logAction({
userId: String(userId),
action,
resource,
resourceId: req.params?.id || (req.body?.id ? String(req.body.id) : undefined),
details: {
durationMs,
query: req.query,
headers: {
host: req.headers.host,
accept: req.headers.accept,
},
},
ipAddress: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'],
request: {
method: req.method,
path: req.path,
body: requestBody,
},
response: {
status: res.statusCode,
},
}).catch((err) => {
console.error('[audit] Failed to write audit entry', err);
});
});

next();
};
}
50 changes: 49 additions & 1 deletion backend/src/routes/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { Router } from 'express';
import { createHash } from 'node:crypto';
import { featureFlags, FeatureFlagName } from '../config/featureFlags.js';
import { featureFlagEngine } from '../services/featureFlags.js';
import { AppError, asyncHandler } from '../middleware/errorHandler.js';
import { paginateArray } from '../utils/pagination.js';

Expand Down Expand Up @@ -65,6 +66,19 @@ flagsRouter.get(
throw new AppError(400, 'Missing flag name or identifier in query', 'VALIDATION_ERROR');
}

const engineFlag = featureFlagEngine.getFlag(flag);
if (engineFlag) {
const isEnabled = featureFlagEngine.evaluate(flag, identifier);
const variant = featureFlagEngine.evaluateVariant(flag, identifier);
res.json({
flag,
identifier,
enabled: isEnabled,
variant,
});
return;
}

const isEnabled = evaluateDeterministic(flag as FeatureFlagName, identifier);

res.json({
Expand All @@ -86,17 +100,29 @@ flagsRouter.get(
}

const allFlags = featureFlags.getAll();
const clientState: Record<string, boolean> = {};
const clientState: Record<string, boolean | string> = {};

allFlags.forEach(f => {
clientState[f.definition.name] = evaluateDeterministic(f.definition.name as FeatureFlagName, identifier);
});

const engineFlags = featureFlagEngine.getAllFlags();
engineFlags.forEach(f => {
const enabled = featureFlagEngine.evaluate(f.name, identifier);
if (enabled) {
const variant = featureFlagEngine.evaluateVariant(f.name, identifier);
clientState[f.name] = variant || true;
} else {
clientState[f.name] = false;
}
});

res.json({ identifier, flags: clientState });
})
);



// ─── ADMIN ENDPOINTS (Existing Code Preserved) ────────────────────────────────

// GET /api/v1/flags
Expand Down Expand Up @@ -184,6 +210,28 @@ flagsRouter.post(
}),
);

// POST /api/v1/flags/exposure
flagsRouter.post(
'/exposure',
asyncHandler(async (req, res) => {
const { flag, identifier, value } = req.body as {
flag?: string; identifier?: string; value?: unknown;
};
if (typeof flag !== 'string' || typeof identifier !== 'string') {
throw new AppError(400, 'flag and identifier are required', 'VALIDATION_ERROR');
}
const engineFlag = featureFlagEngine.getFlag(flag);
if (engineFlag) {
if (typeof value === 'boolean') {
value ? engineFlag.metrics.servedTrue++ : engineFlag.metrics.servedFalse++;
} else if (typeof value === 'string' && engineFlag.metrics.variantsServed) {
engineFlag.metrics.variantsServed[value] = (engineFlag.metrics.variantsServed[value] || 0) + 1;
}
}
res.json({ recorded: true });
})
);

// ─── Serialiser ───────────────────────────────────────────────────────────────

function serializeFlag(flag: ReturnType<typeof featureFlags.get>) {
Expand Down
Loading
Loading