diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts new file mode 100644 index 00000000..a480fa37 --- /dev/null +++ b/backend/src/__tests__/audit-log.test.ts @@ -0,0 +1,419 @@ +/** + * Comprehensive tests for the Audit Logging feature: + * - getAuditSummary() pure function (wildcard, prefix, fallback) + * - DatabaseService audit log CRUD (insert, query, filter, paginate, cleanup) + * - API endpoints (GET /api/audit-log, GET /api/audit-log/export) + * - Permission gating (Admiral + system:audit required) + * - Audit middleware integration (logs mutating requests, skips GETs) + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { getAuditSummary } from '../utils/audit-summaries'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +function authToken(username: string, role: string = 'admin', tv?: number): string { + const payload: Record = { username, role }; + if (tv !== undefined) payload.tv = tv; + return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +function adminToken(): string { + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(TEST_USERNAME)!; + return authToken(TEST_USERNAME, 'admin', user.token_version); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + // Mock LicenseService to return paid/admiral for audit log access + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +// ---- getAuditSummary() unit tests ---- + +describe('getAuditSummary()', () => { + it('resolves prefix match with resource name: POST /stacks/mystack', () => { + expect(getAuditSummary('POST', '/stacks/mystack')).toBe('Created stack: mystack'); + }); + + it('resolves wildcard match: POST /stacks/mystack/deploy', () => { + expect(getAuditSummary('POST', '/stacks/mystack/deploy')).toBe('Deployed stack: mystack'); + }); + + it('resolves wildcard match: POST /stacks/mystack/down', () => { + expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Stopped stack: mystack'); + }); + + it('resolves wildcard match: POST /stacks/mystack/rollback', () => { + expect(getAuditSummary('POST', '/stacks/mystack/rollback')).toBe('Rolled back stack: mystack'); + }); + + it('decodes URL-encoded resource names', () => { + expect(getAuditSummary('POST', '/stacks/my%20stack/deploy')).toBe('Deployed stack: my stack'); + }); + + it('wildcard match wins over prefix when more specific', () => { + // POST /stacks/*/deploy (3 segments) should win over POST /stacks (1 segment prefix) + const result = getAuditSummary('POST', '/stacks/mystack/deploy'); + expect(result).toBe('Deployed stack: mystack'); + expect(result).not.toContain('Created'); + }); + + it('resolves container operations', () => { + expect(getAuditSummary('POST', '/containers/abc123/start')).toBe('Started container: abc123'); + expect(getAuditSummary('POST', '/containers/abc123/stop')).toBe('Stopped container: abc123'); + expect(getAuditSummary('POST', '/containers/abc123/restart')).toBe('Restarted container: abc123'); + }); + + it('resolves fleet snapshot restore with wildcard', () => { + expect(getAuditSummary('POST', '/fleet/snapshots/42/restore')).toBe('Restored fleet backup: 42'); + }); + + it('resolves label actions', () => { + expect(getAuditSummary('POST', '/labels')).toBe('Created label'); + expect(getAuditSummary('POST', '/labels/5/action')).toBe('Executed label action: 5'); + }); + + it('resolves settings routes (POST and PATCH)', () => { + expect(getAuditSummary('POST', '/settings')).toBe('Updated settings'); + expect(getAuditSummary('PATCH', '/settings')).toBe('Updated settings'); + }); + + it('resolves auth operations', () => { + expect(getAuditSummary('PUT', '/auth/password')).toBe('Changed password'); + expect(getAuditSummary('POST', '/auth/generate-node-token')).toBe('Generated node token'); + }); + + it('falls back to generic format for unmapped routes', () => { + expect(getAuditSummary('POST', '/unknown/route')).toBe('POST /api/unknown/route'); + }); + + it('does not match old compose routes (dead entries removed)', () => { + expect(getAuditSummary('POST', '/compose/up')).toBe('POST /api/compose/up'); + expect(getAuditSummary('POST', '/compose/down')).toBe('POST /api/compose/down'); + }); + + it('handles leading slash normalization', () => { + expect(getAuditSummary('DELETE', '/nodes/5')).toBe('Deleted node: 5'); + expect(getAuditSummary('DELETE', 'nodes/5')).toBe('Deleted node: 5'); + }); +}); + +// ---- DatabaseService audit methods ---- + +describe('DatabaseService audit methods', () => { + it('inserts and retrieves an audit log entry', () => { + const db = DatabaseService.getInstance(); + db.insertAuditLog({ + timestamp: Date.now(), + username: 'testuser', + method: 'POST', + path: '/api/stacks/test', + status_code: 201, + node_id: null, + ip_address: '127.0.0.1', + summary: 'Created stack: test', + }); + + const { entries, total } = db.getAuditLogs({ limit: 10 }); + expect(total).toBeGreaterThanOrEqual(1); + const entry = entries.find(e => e.summary === 'Created stack: test'); + expect(entry).toBeDefined(); + expect(entry!.username).toBe('testuser'); + expect(entry!.method).toBe('POST'); + expect(entry!.status_code).toBe(201); + }); + + it('filters by username', () => { + const db = DatabaseService.getInstance(); + db.insertAuditLog({ + timestamp: Date.now(), + username: 'uniquefilteruser', + method: 'DELETE', + path: '/api/nodes/1', + status_code: 200, + node_id: 1, + ip_address: '10.0.0.1', + summary: 'Deleted node: 1', + }); + + const { entries } = db.getAuditLogs({ username: 'uniquefilteruser', limit: 100 }); + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries.every(e => e.username === 'uniquefilteruser')).toBe(true); + }); + + it('filters by method', () => { + const db = DatabaseService.getInstance(); + const { entries } = db.getAuditLogs({ method: 'DELETE', limit: 100 }); + expect(entries.every(e => e.method === 'DELETE')).toBe(true); + }); + + it('filters by date range', () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + + db.insertAuditLog({ + timestamp: now - 100_000, + username: 'rangetest', + method: 'PUT', + path: '/api/settings', + status_code: 200, + node_id: null, + ip_address: '127.0.0.1', + summary: 'Updated settings', + }); + + const { entries } = db.getAuditLogs({ + from: now - 200_000, + to: now - 50_000, + limit: 100, + }); + const found = entries.find(e => e.username === 'rangetest'); + expect(found).toBeDefined(); + + // Outside range should not return the entry + const { entries: outside } = db.getAuditLogs({ + from: now + 100_000, + to: now + 200_000, + limit: 100, + }); + const notFound = outside.find(e => e.username === 'rangetest'); + expect(notFound).toBeUndefined(); + }); + + it('searches across summary, path, and username', () => { + const db = DatabaseService.getInstance(); + db.insertAuditLog({ + timestamp: Date.now(), + username: 'searchableuser', + method: 'POST', + path: '/api/stacks/searchablestack', + status_code: 200, + node_id: null, + ip_address: '127.0.0.1', + summary: 'Deployed stack: searchablestack', + }); + + // Search by summary keyword + const { entries: bySummary } = db.getAuditLogs({ search: 'searchablestack', limit: 100 }); + expect(bySummary.length).toBeGreaterThanOrEqual(1); + + // Search by username + const { entries: byUser } = db.getAuditLogs({ search: 'searchableuser', limit: 100 }); + expect(byUser.length).toBeGreaterThanOrEqual(1); + }); + + it('paginates correctly', () => { + const db = DatabaseService.getInstance(); + // Insert enough entries for pagination + for (let i = 0; i < 5; i++) { + db.insertAuditLog({ + timestamp: Date.now() + i, + username: 'paginateuser', + method: 'POST', + path: `/api/stacks/page${i}`, + status_code: 200, + node_id: null, + ip_address: '127.0.0.1', + summary: `Paginate entry ${i}`, + }); + } + + const page1 = db.getAuditLogs({ username: 'paginateuser', page: 1, limit: 2 }); + const page2 = db.getAuditLogs({ username: 'paginateuser', page: 2, limit: 2 }); + + expect(page1.entries.length).toBe(2); + expect(page2.entries.length).toBe(2); + expect(page1.total).toBe(5); + + // Pages should not overlap + const page1Ids = page1.entries.map(e => e.id); + const page2Ids = page2.entries.map(e => e.id); + expect(page1Ids.some(id => page2Ids.includes(id))).toBe(false); + }); +}); + +// ---- API endpoint tests ---- + +describe('GET /api/audit-log', () => { + it('returns 403 without Admiral license', async () => { + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null); + + const res = await request(app) + .get('/api/audit-log') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(403); + }); + + it('returns 403 for viewer role (no system:audit permission)', async () => { + const db = DatabaseService.getInstance(); + db.addUser({ username: 'vieweraudit', password_hash: 'hash', role: 'viewer' }); + const viewerToken = authToken('vieweraudit', 'viewer'); + + const res = await request(app) + .get('/api/audit-log') + .set('Authorization', `Bearer ${viewerToken}`); + expect(res.status).toBe(403); + }); + + it('returns paginated results for admin with correct structure', async () => { + const res = await request(app) + .get('/api/audit-log?page=1&limit=10') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('entries'); + expect(res.body).toHaveProperty('total'); + expect(Array.isArray(res.body.entries)).toBe(true); + expect(typeof res.body.total).toBe('number'); + }); + + it('respects method filter', async () => { + const res = await request(app) + .get('/api/audit-log?method=DELETE') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + for (const entry of res.body.entries) { + expect(entry.method).toBe('DELETE'); + } + }); + + it('respects search filter', async () => { + const res = await request(app) + .get('/api/audit-log?search=searchablestack') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.body.entries.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('GET /api/audit-log/export', () => { + it('returns 403 without Admiral license', async () => { + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null); + + const res = await request(app) + .get('/api/audit-log/export?format=json') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(403); + }); + + it('exports JSON with correct Content-Type', async () => { + const res = await request(app) + .get('/api/audit-log/export?format=json') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + expect(res.headers['content-disposition']).toContain('audit-log-'); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('exports CSV with correct Content-Type and headers', async () => { + const res = await request(app) + .get('/api/audit-log/export?format=csv') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/csv'); + expect(res.headers['content-disposition']).toContain('audit-log-'); + + const csvText = res.text; + const lines = csvText.split('\n'); + expect(lines[0]).toBe('id,timestamp,username,method,path,status_code,node_id,ip_address,summary'); + expect(lines.length).toBeGreaterThan(1); + }); + + it('respects filters during export', async () => { + const res = await request(app) + .get('/api/audit-log/export?format=json&method=DELETE') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + for (const entry of res.body) { + expect(entry.method).toBe('DELETE'); + } + }); +}); + +// ---- Audit middleware integration ---- + +describe('Audit middleware', () => { + it('logs POST requests with correct data', async () => { + const db = DatabaseService.getInstance(); + const beforeCount = db.getAuditLogs({ limit: 1 }).total; + + // Make a POST request that triggers the audit middleware + await request(app) + .post('/api/users') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ username: 'auditmiddlewaretest', password: 'password123', role: 'viewer' }); + + const afterCount = db.getAuditLogs({ limit: 1 }).total; + expect(afterCount).toBeGreaterThan(beforeCount); + + // The audit entry records the admin who performed the action, not the created user. + // Search by the summary pattern instead. + const { entries } = db.getAuditLogs({ search: 'Created user', method: 'POST', limit: 10 }); + const entry = entries.find(e => e.path === '/api/users'); + expect(entry).toBeDefined(); + expect(entry!.method).toBe('POST'); + expect(entry!.username).toBe(TEST_USERNAME); + }); + + it('does NOT log GET requests', async () => { + const db = DatabaseService.getInstance(); + const beforeCount = db.getAuditLogs({ limit: 1 }).total; + + await request(app) + .get('/api/health') + .set('Authorization', `Bearer ${adminToken()}`); + + const afterCount = db.getAuditLogs({ limit: 1 }).total; + expect(afterCount).toBe(beforeCount); + }); + + it('extracts first IP from X-Forwarded-For', async () => { + const db = DatabaseService.getInstance(); + const beforeTotal = db.getAuditLogs({ limit: 1 }).total; + + await request(app) + .post('/api/users') + .set('Authorization', `Bearer ${adminToken()}`) + .set('X-Forwarded-For', '203.0.113.50, 70.41.3.18, 150.172.238.178') + .send({ username: 'xfftest', password: 'password123', role: 'viewer' }); + + // Get the most recent entry (page 1, sorted by timestamp DESC) + const { entries } = db.getAuditLogs({ method: 'POST', limit: 10 }); + // Find the new entry (total increased) + const afterTotal = db.getAuditLogs({ limit: 1 }).total; + expect(afterTotal).toBeGreaterThan(beforeTotal); + + // The most recent POST /api/users entry should have an IP set + const entry = entries.find(e => e.path === '/api/users' && e.summary === 'Created user'); + expect(entry).toBeDefined(); + expect(entry!.ip_address).toBeDefined(); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index f9012272..0941f081 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -932,93 +932,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { // Audit logging middleware - records all mutating API actions for Admiral accountability. // Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code. -const AUDIT_ROUTE_SUMMARIES: Record = { - 'POST /stacks': 'Created stack', - 'DELETE /stacks': 'Deleted stack', - 'POST /compose/up': 'Deployed stack', - 'POST /compose/down': 'Stopped stack', - 'POST /compose/start': 'Started stack', - 'POST /compose/stop': 'Stopped stack', - 'POST /compose/restart': 'Restarted stack', - 'POST /compose/pull': 'Pulled stack images', - 'POST /system/prune': 'Pruned system resources', - 'POST /nodes': 'Added node', - 'PUT /nodes': 'Updated node', - 'DELETE /nodes': 'Deleted node', - 'POST /users': 'Created user', - 'DELETE /users': 'Deleted user', - 'PUT /users': 'Updated user', - 'POST /license/activate': 'Activated license', - 'POST /license/deactivate': 'Deactivated license', - 'POST /agents': 'Updated notification agent', - 'POST /webhooks': 'Created webhook', - 'PUT /webhooks': 'Updated webhook', - 'DELETE /webhooks': 'Deleted webhook', - 'PUT /settings': 'Updated settings', - 'POST /fleet/snapshots': 'Created fleet backup', - 'DELETE /fleet/snapshots': 'Deleted fleet backup', - 'POST /fleet/snapshots/*/restore': 'Restored fleet backup', - 'PUT /sso/config': 'Updated SSO configuration', - 'DELETE /sso/config': 'Deleted SSO configuration', - 'POST /api-tokens': 'Created API token', - 'DELETE /api-tokens': 'Revoked API token', - 'POST /scheduled-tasks/*/run': 'Triggered scheduled task', - 'POST /scheduled-tasks': 'Created scheduled task', - 'PUT /scheduled-tasks': 'Updated scheduled task', - 'DELETE /scheduled-tasks': 'Deleted scheduled task', - 'PATCH /scheduled-tasks': 'Toggled scheduled task', - 'POST /registries': 'Created registry credential', - 'PUT /registries': 'Updated registry credential', - 'DELETE /registries': 'Deleted registry credential', - 'POST /notification-routes': 'Created notification route', - 'PUT /notification-routes': 'Updated notification route', - 'DELETE /notification-routes': 'Deleted notification route', -}; - -function getAuditSummary(method: string, apiPath: string): string { - const normalized = apiPath.replace(/^\//, ''); - const normalizedSegments = normalized.split('/'); - - // Sort patterns by segment count descending (most specific first) - const sortedEntries = Object.entries(AUDIT_ROUTE_SUMMARIES) - .sort((a, b) => b[0].split('/').length - a[0].split('/').length); - - for (const [pattern, summary] of sortedEntries) { - const spaceIdx = pattern.indexOf(' '); - const pMethod = pattern.slice(0, spaceIdx); - const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, ''); - if (method !== pMethod) continue; - - const patternSegments = pPath.split('/'); - const hasWildcard = patternSegments.includes('*'); - - if (hasWildcard) { - // Wildcard matching: exact segment count, '*' matches any single segment - if (patternSegments.length > normalizedSegments.length) continue; - let match = true; - let resourceName = ''; - for (let i = 0; i < patternSegments.length; i++) { - if (patternSegments[i] === '*') { - resourceName = resourceName || normalizedSegments[i]; - } else if (patternSegments[i] !== normalizedSegments[i]) { - match = false; - break; - } - } - if (match) { - return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; - } - } else { - // Prefix matching (original behavior) - if (normalized.startsWith(pPath)) { - const rest = normalized.slice(pPath.length).replace(/^\//, ''); - const resourceName = rest.split('/')[0]; - return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; - } - } - } - return `${method} /api/${normalized}`; -} +import { getAuditSummary } from './utils/audit-summaries'; app.use('/api', (req: Request, res: Response, next: NextFunction): void => { if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { @@ -1028,11 +942,16 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { const username = req.user?.username || 'unknown'; const nodeId = req.nodeId ?? null; - const ip = req.ip || req.headers['x-forwarded-for'] as string || ''; + const forwarded = req.headers['x-forwarded-for']; + const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : ''; + const ip = req.ip || xff || ''; const apiPath = req.path; res.on('finish', () => { try { + if (isDebugEnabled()) { + console.log(`[Audit:diag] ${req.method} /api${apiPath} by=${username} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${ip}`); + } DatabaseService.getInstance().insertAuditLog({ timestamp: Date.now(), username, @@ -4812,6 +4731,9 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise => const from = req.query.from ? parseInt(req.query.from as string) : undefined; const to = req.query.to ? parseInt(req.query.to as string) : undefined; + if (isDebugEnabled()) { + console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${username || '-'} method=${method || '-'} search=${search || '-'}`); + } const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search }); res.json(result); } catch (error) { @@ -4832,6 +4754,9 @@ app.get('/api/audit-log/export', async (req: Request, res: Response): Promise = { + // Stack CRUD + 'POST /stacks': 'Created stack', + 'DELETE /stacks': 'Deleted stack', + 'PUT /stacks/*/env': 'Updated stack env file', + 'PUT /stacks': 'Updated stack file', + + // Stack lifecycle + 'POST /stacks/*/deploy': 'Deployed stack', + 'POST /stacks/*/down': 'Stopped stack', + 'POST /stacks/*/start': 'Started stack', + 'POST /stacks/*/stop': 'Stopped stack', + 'POST /stacks/*/restart': 'Restarted stack', + 'POST /stacks/*/update': 'Updated stack images', + 'POST /stacks/*/rollback': 'Rolled back stack', + + // Container operations + 'POST /containers/*/start': 'Started container', + 'POST /containers/*/stop': 'Stopped container', + 'POST /containers/*/restart': 'Restarted container', + + // System operations + 'POST /system/prune': 'Pruned system resources', + 'POST /system/prune/orphans': 'Pruned orphan containers', + 'POST /system/prune/system': 'Pruned system resources', + 'POST /system/images/delete': 'Deleted images', + 'POST /system/volumes/delete': 'Deleted volumes', + 'POST /system/networks/delete': 'Deleted networks', + 'POST /system/networks': 'Created network', + 'POST /system/console-token': 'Generated console token', + + // Node management + 'POST /nodes': 'Added node', + 'PUT /nodes': 'Updated node', + 'DELETE /nodes': 'Deleted node', + + // User management + 'POST /users': 'Created user', + 'DELETE /users': 'Deleted user', + 'PUT /users': 'Updated user', + 'POST /users/*/roles': 'Assigned role', + 'DELETE /users/*/roles': 'Removed role assignment', + + // Auth + 'PUT /auth/password': 'Changed password', + 'POST /auth/generate-node-token': 'Generated node token', + + // License + 'POST /license/activate': 'Activated license', + 'POST /license/deactivate': 'Deactivated license', + + // Notifications & Agents + 'POST /agents': 'Updated notification agent', + 'POST /notifications/test': 'Tested notification', + 'POST /notification-routes': 'Created notification route', + 'PUT /notification-routes': 'Updated notification route', + 'DELETE /notification-routes': 'Deleted notification route', + 'POST /notification-routes/*/test': 'Tested notification route', + + // Webhooks + 'POST /webhooks': 'Created webhook', + 'PUT /webhooks': 'Updated webhook', + 'DELETE /webhooks': 'Deleted webhook', + + // Settings + 'POST /settings': 'Updated settings', + 'PATCH /settings': 'Updated settings', + + // Fleet + 'POST /fleet/snapshots': 'Created fleet backup', + 'DELETE /fleet/snapshots': 'Deleted fleet backup', + 'POST /fleet/snapshots/*/restore': 'Restored fleet backup', + 'POST /fleet/nodes/*/update': 'Triggered fleet node update', + 'POST /fleet/update-all': 'Triggered fleet-wide update', + + // SSO + 'PUT /sso/config': 'Updated SSO configuration', + 'DELETE /sso/config': 'Deleted SSO configuration', + 'POST /sso/config/*/test': 'Tested SSO configuration', + + // API tokens + 'POST /api-tokens': 'Created API token', + 'DELETE /api-tokens': 'Revoked API token', + + // Scheduled tasks + 'POST /scheduled-tasks/*/run': 'Triggered scheduled task', + 'POST /scheduled-tasks': 'Created scheduled task', + 'PUT /scheduled-tasks': 'Updated scheduled task', + 'DELETE /scheduled-tasks': 'Deleted scheduled task', + 'PATCH /scheduled-tasks': 'Toggled scheduled task', + + // Registries + 'POST /registries': 'Created registry credential', + 'PUT /registries': 'Updated registry credential', + 'DELETE /registries': 'Deleted registry credential', + + // Labels + 'POST /labels': 'Created label', + 'PUT /labels': 'Updated label', + 'DELETE /labels': 'Deleted label', + 'POST /labels/*/action': 'Executed label action', + 'PUT /stacks/*/labels': 'Updated stack labels', + + // Templates + 'POST /templates/deploy': 'Deployed template', + + // Auto-update + 'POST /auto-update/execute': 'Executed auto-update', +}; + +// Pre-sorted at module load: most specific patterns (by segment count) first. +const SORTED_PATTERNS = Object.entries(AUDIT_ROUTE_SUMMARIES) + .sort((a, b) => b[0].split('/').length - a[0].split('/').length); + +/** + * Resolve a human-readable summary for an audit log entry. + * + * Tries wildcard patterns first (most specific by segment count), then + * falls back to prefix matching. Returns a generic method+path string + * if no pattern matches. + */ +export function getAuditSummary(method: string, apiPath: string): string { + const normalized = apiPath.replace(/^\//, ''); + const normalizedSegments = normalized.split('/'); + + for (const [pattern, summary] of SORTED_PATTERNS) { + const spaceIdx = pattern.indexOf(' '); + const pMethod = pattern.slice(0, spaceIdx); + const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, ''); + if (method !== pMethod) continue; + + const patternSegments = pPath.split('/'); + const hasWildcard = patternSegments.includes('*'); + + if (hasWildcard) { + // Wildcard matching: pattern segments must not exceed actual segments + if (patternSegments.length > normalizedSegments.length) continue; + let match = true; + let resourceName = ''; + for (let i = 0; i < patternSegments.length; i++) { + if (patternSegments[i] === '*') { + resourceName = resourceName || normalizedSegments[i]; + } else if (patternSegments[i] !== normalizedSegments[i]) { + match = false; + break; + } + } + if (match) { + return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; + } + } else { + // Prefix matching (original behavior) + if (normalized.startsWith(pPath)) { + const rest = normalized.slice(pPath.length).replace(/^\//, ''); + const resourceName = rest.split('/')[0]; + return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary; + } + } + } + return `${method} /api/${normalized}`; +} diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index c98d5960..35815f57 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -33,17 +33,29 @@ Expanding a row reveals additional detail: ### Example actions tracked -- Stack lifecycle: deploy, stop, start, restart, pull, delete -- Stack creation and file edits +- Stack lifecycle: deploy, stop, start, restart, update, rollback, delete +- Stack creation, file edits, and env file changes +- Container operations: start, stop, restart - Node management: add, update, delete -- User management: create, delete, role changes +- User management: create, delete, role assignment and removal +- Password changes and node token generation - Settings changes -- System prune operations +- System prune operations (general, orphans, system) +- Image, volume, and network deletion +- Network creation - License activation/deactivation - Webhook and notification agent configuration +- Notification route management and testing - Fleet backup creation, restoration, and deletion +- Fleet node updates (individual and fleet-wide) - Scheduled task management - Registry credential management +- SSO configuration changes and connection tests +- API token creation and revocation +- Label management and bulk actions +- Template deployments +- Auto-update execution +- Console token generation ## Viewing the audit log diff --git a/docs/images/audit-log/audit-log-expanded.png b/docs/images/audit-log/audit-log-expanded.png index 247bfdb6..53ec7f88 100644 Binary files a/docs/images/audit-log/audit-log-expanded.png and b/docs/images/audit-log/audit-log-expanded.png differ diff --git a/docs/images/audit-log/audit-log-overview.png b/docs/images/audit-log/audit-log-overview.png index aae64823..ba7f3a92 100644 Binary files a/docs/images/audit-log/audit-log-overview.png and b/docs/images/audit-log/audit-log-overview.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a1d7ea61..7b4264eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,12 +36,14 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "cronstrue": "^3.14.0", + "date-fns": "^4.1.0", "geist": "^1.7.0", "lucide-react": "^1.8.0", "monaco-editor": "^0.55.1", "motion": "^12.38.0", "radix-ui": "^1.4.3", "react": "^19.2.5", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", "react-is": "^19.2.5", "react-use-measure": "^2.1.7", @@ -323,6 +325,12 @@ "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", "license": "MIT" }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -2723,6 +2731,15 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -3970,6 +3987,22 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5349,6 +5382,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7b6f3165..ece8c136 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,12 +38,14 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "cronstrue": "^3.14.0", + "date-fns": "^4.1.0", "geist": "^1.7.0", "lucide-react": "^1.8.0", "monaco-editor": "^0.55.1", "motion": "^12.38.0", "radix-ui": "^1.4.3", "react": "^19.2.5", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", "react-is": "^19.2.5", "react-use-measure": "^2.1.7", diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index b18b7b69..8aa82dce 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useCallback, Fragment } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Combobox } from '@/components/ui/combobox'; +import { DatePicker } from '@/components/ui/date-picker'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; @@ -22,6 +23,14 @@ interface AuditEntry { summary: string; } +const methodOptions = [ + { value: 'all', label: 'All Methods' }, + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'DELETE', label: 'DELETE' }, + { value: 'PATCH', label: 'PATCH' }, +]; + export function AuditLogView() { const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); @@ -29,8 +38,8 @@ export function AuditLogView() { const [loading, setLoading] = useState(true); const [searchFilter, setSearchFilter] = useState(''); const [methodFilter, setMethodFilter] = useState('all'); - const [fromDate, setFromDate] = useState(''); - const [toDate, setToDate] = useState(''); + const [fromDate, setFromDate] = useState(); + const [toDate, setToDate] = useState(); const [expandedId, setExpandedId] = useState(null); const limit = 50; @@ -38,7 +47,11 @@ export function AuditLogView() { const params = new URLSearchParams(); if (searchFilter) params.set('search', searchFilter); if (methodFilter !== 'all') params.set('method', methodFilter); - if (fromDate) params.set('from', String(new Date(fromDate).getTime())); + if (fromDate) { + const start = new Date(fromDate); + start.setHours(0, 0, 0, 0); + params.set('from', String(start.getTime())); + } if (toDate) { const end = new Date(toDate); end.setHours(23, 59, 59, 999); @@ -60,8 +73,8 @@ export function AuditLogView() { setEntries(data.entries); setTotal(data.total); } - } catch { - // Silently fail - non-critical view + } catch (err) { + console.error('[AuditLog] Failed to fetch:', err); } finally { setLoading(false); } @@ -84,8 +97,8 @@ export function AuditLogView() { const statusColor = (code: number): string => { if (code >= 200 && code < 300) return 'text-success'; - if (code >= 400 && code < 500) return 'text-yellow-500'; - if (code >= 500) return 'text-red-500'; + if (code >= 400 && code < 500) return 'text-warning'; + if (code >= 500) return 'text-destructive'; return 'text-muted-foreground'; }; @@ -108,27 +121,28 @@ export function AuditLogView() { a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - } catch { + } catch (err) { + console.error('[AuditLog] Export failed:', err); toast.error('Export failed.'); } }; return (
- +
- + Audit Log
@@ -137,7 +151,7 @@ export function AuditLogView() {
@@ -150,7 +164,7 @@ export function AuditLogView() { {/* Filters */}
- +
- - { setFromDate(e.target.value); setPage(1); }} - className="w-[150px]" - placeholder="From" + { setMethodFilter(v || 'all'); setPage(1); }} + placeholder="Method" + className="w-[140px]" /> - { setFromDate(d); setPage(1); }} + placeholder="From" + className="w-[160px]" + /> + { setToDate(e.target.value); setPage(1); }} - className="w-[150px]" + onChange={(d) => { setToDate(d); setPage(1); }} placeholder="To" + className="w-[160px]" />
@@ -219,7 +226,7 @@ export function AuditLogView() { className="cursor-pointer hover:bg-muted/50" onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)} > - + {new Date(entry.timestamp).toLocaleString()} @@ -233,10 +240,10 @@ export function AuditLogView() { {entry.summary} - + {entry.status_code} - + {entry.node_id ?? '-'} @@ -250,15 +257,15 @@ export function AuditLogView() {
IP Address - {entry.ip_address || '-'} + {entry.ip_address || '-'}
Node ID - {entry.node_id ?? 'Local'} + {entry.node_id ?? 'Local'}
Entry ID - #{entry.id} + #{entry.id}
@@ -274,15 +281,15 @@ export function AuditLogView() { {/* Pagination */} {totalPages > 1 && (
-

+

Page {page} of {totalPages}

diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 00000000..e2f6c068 --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,65 @@ +import { DayPicker, getDefaultClassNames } from "react-day-picker"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +import "react-day-picker/style.css"; + +export type CalendarProps = React.ComponentProps; + +function CalendarChevron({ orientation }: { orientation?: "left" | "right" | "up" | "down" }) { + return orientation === "left" ? ( + + ) : ( + + ); +} + +export function Calendar({ className, classNames, ...props }: CalendarProps) { + const defaults = getDefaultClassNames(); + + return ( + + ); +} diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx new file mode 100644 index 00000000..35c11035 --- /dev/null +++ b/frontend/src/components/ui/date-picker.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { format } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; + +interface DatePickerProps { + value: Date | undefined; + onChange: (date: Date | undefined) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function DatePicker({ + value, + onChange, + placeholder = "Pick a date", + disabled = false, + className, +}: DatePickerProps) { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + { + onChange(date); + setOpen(false); + }} + autoFocus + /> + + + ); +}