feat: Implement centralized audit logging admin interface (refs #10)
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
- Add audit_logs table with categories, severities, and indexes - Create AuditLogService and AuditLogRepository - Add REST API endpoints for viewing and exporting logs - Wire audit logging into auth, vehicles, admin, and backup features - Add desktop AdminLogsPage with filters and CSV export - Add mobile AdminLogsMobileScreen with card layout - Implement 90-day retention cleanup job - Remove old AuditLogPanel from AdminCatalogPage Security fixes: - Escape LIKE special characters to prevent pattern injection - Limit CSV export to 5000 records to prevent memory exhaustion - Add truncation warning headers for large exports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
154
backend/src/features/audit-log/api/audit-log.controller.ts
Normal file
154
backend/src/features/audit-log/api/audit-log.controller.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for audit log API
|
||||
* @ai-context HTTP request/response handling for audit log search and export
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { AuditLogService } from '../domain/audit-log.service';
|
||||
import { AuditLogRepository } from '../data/audit-log.repository';
|
||||
import { AuditLogFilters, isValidCategory, isValidSeverity } from '../domain/audit-log.types';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
interface AuditLogsQuery {
|
||||
search?: string;
|
||||
category?: string;
|
||||
severity?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
export class AuditLogController {
|
||||
private service: AuditLogService;
|
||||
|
||||
constructor() {
|
||||
const repository = new AuditLogRepository(pool);
|
||||
this.service = new AuditLogService(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/audit-logs - Search audit logs with filters
|
||||
*/
|
||||
async getAuditLogs(
|
||||
request: FastifyRequest<{ Querystring: AuditLogsQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { search, category, severity, startDate, endDate, limit, offset } = request.query;
|
||||
|
||||
// Validate category if provided
|
||||
if (category && !isValidCategory(category)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: `Invalid category: ${category}. Valid values: auth, vehicle, user, system, admin`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate severity if provided
|
||||
if (severity && !isValidSeverity(severity)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: `Invalid severity: ${severity}. Valid values: info, warning, error`,
|
||||
});
|
||||
}
|
||||
|
||||
const filters: AuditLogFilters = {
|
||||
search,
|
||||
category: category as AuditLogFilters['category'],
|
||||
severity: severity as AuditLogFilters['severity'],
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
};
|
||||
|
||||
const pagination = {
|
||||
limit: Math.min(parseInt(limit || '50', 10), 100),
|
||||
offset: parseInt(offset || '0', 10),
|
||||
};
|
||||
|
||||
const result = await this.service.search(filters, pagination);
|
||||
|
||||
return reply.send(result);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching audit logs', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to fetch audit logs',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/audit-logs/export - Export audit logs as CSV
|
||||
*/
|
||||
async exportAuditLogs(
|
||||
request: FastifyRequest<{ Querystring: AuditLogsQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { search, category, severity, startDate, endDate } = request.query;
|
||||
|
||||
// Validate category if provided
|
||||
if (category && !isValidCategory(category)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: `Invalid category: ${category}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate severity if provided
|
||||
if (severity && !isValidSeverity(severity)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: `Invalid severity: ${severity}`,
|
||||
});
|
||||
}
|
||||
|
||||
const filters: AuditLogFilters = {
|
||||
search,
|
||||
category: category as AuditLogFilters['category'],
|
||||
severity: severity as AuditLogFilters['severity'],
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
};
|
||||
|
||||
const { logs, truncated } = await this.service.getForExport(filters);
|
||||
|
||||
// Generate CSV
|
||||
const headers = ['ID', 'Timestamp', 'Category', 'Severity', 'User ID', 'Action', 'Resource Type', 'Resource ID'];
|
||||
const rows = logs.map((log) => [
|
||||
log.id,
|
||||
log.createdAt.toISOString(),
|
||||
log.category,
|
||||
log.severity,
|
||||
log.userId || '',
|
||||
`"${log.action.replace(/"/g, '""')}"`, // Escape quotes in CSV
|
||||
log.resourceType || '',
|
||||
log.resourceId || '',
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||
|
||||
// Set headers for file download
|
||||
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
reply.header('Content-Type', 'text/csv');
|
||||
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
// Warn if results were truncated
|
||||
if (truncated) {
|
||||
reply.header('X-Export-Truncated', 'true');
|
||||
reply.header('X-Export-Limit', '5000');
|
||||
logger.warn('Audit log export was truncated', { exportedCount: logs.length });
|
||||
}
|
||||
|
||||
return reply.send(csv);
|
||||
} catch (error) {
|
||||
logger.error('Error exporting audit logs', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to export audit logs',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user