feat(audit-log): add configurable retention, export, Auditor role, and enhanced filtering (#258)

- Configurable retention: audit_retention_days setting (1-365 days, default 90)
  replaces hardcoded 90-day retention, exposed in Settings > Data Retention
- Export: one-click CSV/JSON export of filtered audit data via new
  GET /api/audit-log/export endpoint (capped at 10,000 entries)
- Auditor role: read-only role with system:audit permission for viewing
  and exporting audit logs without admin privileges (Admiral tier)
- Enhanced filtering: full-text search across summaries/paths/usernames,
  date range picker, and expandable row details showing request path,
  IP address, node ID, and entry ID
This commit is contained in:
Anso
2026-03-29 20:18:51 -04:00
committed by GitHub
parent f4428a394c
commit d586ce393a
11 changed files with 289 additions and 65 deletions
+58 -8
View File
@@ -832,6 +832,9 @@ const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
viewer: [
'stack:read', 'node:read',
],
auditor: [
'stack:read', 'node:read', 'system:audit',
],
};
/**
@@ -1765,12 +1768,12 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
return;
}
if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return;
if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return;
const db = DatabaseService.getInstance();
const existing = db.getUserByUsername(username);
@@ -1832,12 +1835,12 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
}
if (role !== undefined) {
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
return;
}
if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return;
if ((role === 'deployer' || role === 'node-admin' || role === 'auditor') && !requireAdmiral(req, res)) return;
// Prevent demoting yourself
if (user.username === req.user!.username && role !== user.role) {
res.status(400).json({ error: 'Cannot change your own role' });
@@ -3177,6 +3180,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'template_registry_url',
'metrics_retention_hours',
'log_retention_days',
'audit_retention_days',
]);
// Zod schema for bulk PATCH - all keys optional, present keys fully validated
@@ -3192,6 +3196,7 @@ const SettingsPatchSchema = z.object({
template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }),
metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String),
log_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
}).partial();
app.get('/api/settings', async (req: Request, res: Response) => {
@@ -3485,18 +3490,19 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response):
// --- Audit Log Routes (Admiral, local-only) ---
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to });
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search });
res.json(result);
} catch (error) {
console.error('[AuditLog] Failed to fetch audit log:', error);
@@ -3504,6 +3510,50 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
}
});
app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const format = (req.query.format as string) === 'csv' ? 'csv' : 'json';
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
const result = DatabaseService.getInstance().getAuditLogs({ page: 1, limit: 10000, username, method, from, to, search });
const timestamp = new Date().toISOString().slice(0, 10);
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.json"`);
res.json(result.entries);
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
const csvEscape = (val: string | number | null): string => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const rows = result.entries.map(e =>
headers.map(h => csvEscape(e[h as keyof typeof e])).join(',')
);
res.send([headers.join(','), ...rows].join('\n'));
}
} catch (error) {
console.error('[AuditLog] Export failed:', error);
res.status(500).json({ error: 'Failed to export audit log' });
}
});
// --- API Token Routes (Admiral, admin-only, local-only) ---
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
+7 -1
View File
@@ -62,7 +62,7 @@ export interface WebhookExecution {
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type ResourceType = 'stack' | 'node';
export interface User {
@@ -1109,6 +1109,7 @@ export class DatabaseService {
method?: string;
from?: number;
to?: number;
search?: string;
} = {}): { entries: AuditLogEntry[]; total: number } {
const page = filters.page ?? 1;
const limit = filters.limit ?? 50;
@@ -1133,6 +1134,11 @@ export class DatabaseService {
conditions.push('timestamp <= ?');
params.push(filters.to);
}
if (filters.search) {
conditions.push('(summary LIKE ? OR path LIKE ? OR username LIKE ?)');
const term = `%${filters.search}%`;
params.push(term, term, term);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
+2 -1
View File
@@ -304,7 +304,8 @@ export class MonitorService {
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
db.cleanupOldAuditLogs(90);
const auditRetentionDays = parseInt(settings['audit_retention_days'] || '90', 10);
db.cleanupOldAuditLogs(isNaN(auditRetentionDays) ? 90 : auditRetentionDays);
} catch (e) {
console.error('MonitorService: failed to cleanup old data', e);
}