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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 1 addition & 4 deletions src/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
56 changes: 56 additions & 0 deletions src/middleware/adminLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { adminLogMiddleware, adminLogger } from './adminLog.js';
import { type Request, type Response } from 'express';

describe('Admin Logging Middleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
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<string, () => 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);
});
});
31 changes: 31 additions & 0 deletions src/middleware/adminLog.ts
Original file line number Diff line number Diff line change
@@ -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();
};
5 changes: 3 additions & 2 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, string>);
Expand Down Expand Up @@ -211,4 +212,4 @@ router.use('/webhooks', createAdminWebhooksRouter());
// ---------------------------------------------------------------------------
router.use('/apis', createAdminApisRouter());

export default router;
export default router;