diff --git a/README.md b/README.md index cb4de9a..5a0c63d 100644 --- a/README.md +++ b/README.md @@ -381,3 +381,6 @@ Notes: This repo is part of [Callora](https://github.com/your-org/callora): - Frontend: `callora-frontend` - Contracts: `callora-contracts` + +## Security Audit Logging +Admin events are routed into an isolated, structured Pino log stream containing the channel label `admin_action` for clean alerting profiles. diff --git a/src/errors/codes.ts b/src/errors/codes.ts index fbbeb6d..7a84f1d 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -244,10 +244,7 @@ export const ErrorCode = { UNSUPPORTED_MEDIA_TYPE: "UNSUPPORTED_MEDIA_TYPE", /** Request is syntactically correct but semantically invalid */ - UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY", - - /** Usage aggregate not found for the given developer */ - USAGE_AGGREGATE_NOT_FOUND: "USAGE_AGGREGATE_NOT_FOUND" + UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY" } as const; diff --git a/src/middleware/adminLog.test.ts b/src/middleware/adminLog.test.ts new file mode 100644 index 0000000..ca33100 --- /dev/null +++ b/src/middleware/adminLog.test.ts @@ -0,0 +1,56 @@ +import { adminLogMiddleware, adminLogger } from './adminLog.js'; +import { type Request, type Response } from 'express'; + +describe('Admin Logging Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(adminLogger, 'info').mockImplementation(() => adminLogger); + + mockRequest = { + method: 'POST', + path: '/users', + baseUrl: '/api/admin', + ip: '127.0.0.1', + get: jest.fn().mockReturnValue('test-agent'), + }; + + const callbacks: Record void> = {}; + mockResponse = { + statusCode: 201, + locals: { adminActor: 'admin@callora.io' }, + on: jest.fn().mockImplementation((event, callback) => { + callbacks[event] = callback; + return mockResponse; + }), + }; + + // Simulate response ending to trigger the logger + (mockResponse.on as jest.Mock).mockImplementation((event, callback) => { + if (event === 'finish') setTimeout(callback, 0); + return mockResponse; + }); + }); + + it('should successfully pass to next and log structured admin metadata', (done) => { + adminLogMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + + setTimeout(() => { + expect(adminLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/api/admin/users', + statusCode: 201, + actor: 'admin@callora.io', + }), + expect.any(String) + ); + done(); + }, 5); + }); +}); diff --git a/src/middleware/adminLog.ts b/src/middleware/adminLog.ts new file mode 100644 index 0000000..36279ac --- /dev/null +++ b/src/middleware/adminLog.ts @@ -0,0 +1,31 @@ +import { type Request, type Response, type NextFunction } from 'express'; +import { logger } from './logging.js'; + +// Create a dedicated pino child logger for admin actions separate from standard requests +export const adminLogger = logger.child({ + channel: 'admin_action', +}); + +export const adminLogMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const startTime = process.hrtime.bigint(); + + res.on('finish', () => { + const endTime = process.hrtime.bigint(); + const durationMs = Number(endTime - startTime) / 1_000_000; + + // Use the route's authenticated actor field if available + const actor = res.locals.adminActor || 'unknown_admin'; + + adminLogger.info({ + method: req.method, + path: req.baseUrl + req.path, + statusCode: res.statusCode, + actor, + durationMs, + ip: req.ip, + userAgent: req.get('user-agent'), + }, `Admin action completed: ${req.method} ${req.baseUrl}${req.path}`); + }); + + next(); +}; diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a692bb9..1fd0dc5 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,3 +1,4 @@ +import { adminLogMiddleware } from '../middleware/adminLog.js'; import { Router, type Response } from 'express'; import { adminAuth } from '../middleware/adminAuth.js'; import { createAdminIpAllowlist } from '../middleware/ipAllowlist.js'; @@ -24,7 +25,7 @@ const router = Router(); // Apply IP allowlist check before authentication router.use(createAdminIpAllowlist()); router.use(adminAuth); - +router.use(adminLogMiddleware); // <--- Add this line here! router.get('/users', async (req, res, next) => { try { const { limit, offset } = parsePagination(req.query as Record); @@ -211,4 +212,4 @@ router.use('/webhooks', createAdminWebhooksRouter()); // --------------------------------------------------------------------------- router.use('/apis', createAdminApisRouter()); -export default router; \ No newline at end of file +export default router;