From c98211f4a287278c7b7f99cc92ce36df10d8f905 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:09:09 -0600 Subject: [PATCH 1/6] feat: Implement centralized audit logging admin interface (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/app.ts | 2 + backend/src/core/scheduler/index.ts | 31 +- backend/src/features/CLAUDE.md | 1 + .../features/admin/domain/admin.service.ts | 37 +- backend/src/features/audit-log/CLAUDE.md | 55 +++ backend/src/features/audit-log/README.md | 168 ++++++++ .../__tests__/audit-log.integration.test.ts | 308 ++++++++++++++ .../__tests__/audit-log.routes.test.ts | 126 ++++++ .../__tests__/audit-log.service.test.ts | 207 +++++++++ .../audit-log/__tests__/migrations.test.ts | 130 ++++++ .../audit-log/api/audit-log.controller.ts | 154 +++++++ .../audit-log/api/audit-log.routes.ts | 50 +++ .../features/audit-log/audit-log.instance.ts | 14 + .../audit-log/data/audit-log.repository.ts | 232 ++++++++++ .../audit-log/domain/audit-log.service.ts | 163 +++++++ .../audit-log/domain/audit-log.types.ts | 106 +++++ backend/src/features/audit-log/index.ts | 28 ++ .../features/audit-log/jobs/cleanup.job.ts | 74 ++++ .../migrations/001_create_audit_logs.sql | 35 ++ .../src/features/auth/api/auth.controller.ts | 20 + .../features/backup/api/backup.controller.ts | 43 ++ .../vehicles/domain/vehicles.service.ts | 38 +- frontend/src/App.tsx | 28 ++ frontend/src/core/store/navigation.ts | 2 +- frontend/src/features/admin/api/admin.api.ts | 35 ++ .../src/features/admin/hooks/useAuditLogs.ts | 59 +++ .../admin/mobile/AdminLogsMobileScreen.tsx | 321 ++++++++++++++ .../src/features/admin/types/admin.types.ts | 36 ++ frontend/src/pages/admin/AdminCatalogPage.tsx | 4 - frontend/src/pages/admin/AdminLogsPage.tsx | 401 ++++++++++++++++++ 30 files changed, 2897 insertions(+), 11 deletions(-) create mode 100644 backend/src/features/audit-log/CLAUDE.md create mode 100644 backend/src/features/audit-log/README.md create mode 100644 backend/src/features/audit-log/__tests__/audit-log.integration.test.ts create mode 100644 backend/src/features/audit-log/__tests__/audit-log.routes.test.ts create mode 100644 backend/src/features/audit-log/__tests__/audit-log.service.test.ts create mode 100644 backend/src/features/audit-log/__tests__/migrations.test.ts create mode 100644 backend/src/features/audit-log/api/audit-log.controller.ts create mode 100644 backend/src/features/audit-log/api/audit-log.routes.ts create mode 100644 backend/src/features/audit-log/audit-log.instance.ts create mode 100644 backend/src/features/audit-log/data/audit-log.repository.ts create mode 100644 backend/src/features/audit-log/domain/audit-log.service.ts create mode 100644 backend/src/features/audit-log/domain/audit-log.types.ts create mode 100644 backend/src/features/audit-log/index.ts create mode 100644 backend/src/features/audit-log/jobs/cleanup.job.ts create mode 100644 backend/src/features/audit-log/migrations/001_create_audit_logs.sql create mode 100644 frontend/src/features/admin/hooks/useAuditLogs.ts create mode 100644 frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx create mode 100644 frontend/src/pages/admin/AdminLogsPage.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index 4c90489..22c4ec8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,7 @@ import { documentsRoutes } from './features/documents/api/documents.routes'; import { maintenanceRoutes } from './features/maintenance'; import { platformRoutes } from './features/platform'; import { adminRoutes } from './features/admin/api/admin.routes'; +import { auditLogRoutes } from './features/audit-log/api/audit-log.routes'; import { notificationsRoutes } from './features/notifications'; import { userProfileRoutes } from './features/user-profile'; import { onboardingRoutes } from './features/onboarding'; @@ -137,6 +138,7 @@ async function buildApp(): Promise { await app.register(communityStationsRoutes, { prefix: '/api' }); await app.register(maintenanceRoutes, { prefix: '/api' }); await app.register(adminRoutes, { prefix: '/api' }); + await app.register(auditLogRoutes, { prefix: '/api' }); await app.register(notificationsRoutes, { prefix: '/api' }); await app.register(userProfileRoutes, { prefix: '/api' }); await app.register(userPreferencesRoutes, { prefix: '/api' }); diff --git a/backend/src/core/scheduler/index.ts b/backend/src/core/scheduler/index.ts index 012c1db..c6dc63c 100644 --- a/backend/src/core/scheduler/index.ts +++ b/backend/src/core/scheduler/index.ts @@ -15,6 +15,10 @@ import { processBackupRetention, setBackupCleanupJobPool, } from '../../features/backup/jobs/backup-cleanup.job'; +import { + processAuditLogCleanup, + setAuditLogCleanupJobPool, +} from '../../features/audit-log/jobs/cleanup.job'; import { pool } from '../config/database'; let schedulerInitialized = false; @@ -31,6 +35,9 @@ export function initializeScheduler(): void { setBackupJobPool(pool); setBackupCleanupJobPool(pool); + // Initialize audit log cleanup job pool + setAuditLogCleanupJobPool(pool); + // Daily notification processing at 8 AM cron.schedule('0 8 * * *', async () => { logger.info('Running scheduled notification job'); @@ -90,8 +97,30 @@ export function initializeScheduler(): void { } }); + // Audit log retention cleanup at 3 AM daily (90-day retention) + cron.schedule('0 3 * * *', async () => { + logger.info('Running audit log cleanup job'); + try { + const result = await processAuditLogCleanup(); + if (result.success) { + logger.info('Audit log cleanup job completed', { + deletedCount: result.deletedCount, + retentionDays: result.retentionDays, + }); + } else { + logger.error('Audit log cleanup job failed', { + error: result.error, + }); + } + } catch (error) { + logger.error('Audit log cleanup job failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + }); + schedulerInitialized = true; - logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), backup check (every min), retention cleanup (4 AM)'); + logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), audit log cleanup (3 AM), backup check (every min), retention cleanup (4 AM)'); } export function isSchedulerInitialized(): boolean { diff --git a/backend/src/features/CLAUDE.md b/backend/src/features/CLAUDE.md index af2a782..2a84ce4 100644 --- a/backend/src/features/CLAUDE.md +++ b/backend/src/features/CLAUDE.md @@ -7,6 +7,7 @@ Feature capsule directory. Each feature is 100% self-contained with api/, domain | Directory | What | When to read | | --------- | ---- | ------------ | | `admin/` | Admin role management, catalog CRUD | Admin functionality, user oversight | +| `audit-log/` | Centralized audit logging | Cross-feature event logging, admin logs UI | | `auth/` | Authentication endpoints | Login, logout, session management | | `backup/` | Database backup and restore | Backup jobs, data export/import | | `documents/` | Document storage and management | File uploads, document handling | diff --git a/backend/src/features/admin/domain/admin.service.ts b/backend/src/features/admin/domain/admin.service.ts index 1755d98..00216b0 100644 --- a/backend/src/features/admin/domain/admin.service.ts +++ b/backend/src/features/admin/domain/admin.service.ts @@ -6,6 +6,7 @@ import { AdminRepository } from '../data/admin.repository'; import { AdminUser, AdminAuditLog } from './admin.types'; import { logger } from '../../../core/logging/logger'; +import { auditLogService } from '../../audit-log'; export class AdminService { constructor(private repository: AdminRepository) {} @@ -58,12 +59,22 @@ export class AdminService { // Create new admin const admin = await this.repository.createAdmin(auth0Sub, normalizedEmail, role, createdBy); - // Log audit action + // Log audit action (legacy) await this.repository.logAuditAction(createdBy, 'CREATE', admin.auth0Sub, 'admin_user', admin.email, { email, role }); + // Log to unified audit log + await auditLogService.info( + 'admin', + createdBy, + `Admin user created: ${admin.email}`, + 'admin_user', + admin.auth0Sub, + { email: admin.email, role } + ).catch(err => logger.error('Failed to log admin create audit event', { error: err })); + logger.info('Admin user created', { email, role }); return admin; } catch (error) { @@ -83,9 +94,19 @@ export class AdminService { // Revoke the admin const admin = await this.repository.revokeAdmin(auth0Sub); - // Log audit action + // Log audit action (legacy) await this.repository.logAuditAction(revokedBy, 'REVOKE', auth0Sub, 'admin_user', admin.email); + // Log to unified audit log + await auditLogService.info( + 'admin', + revokedBy, + `Admin user revoked: ${admin.email}`, + 'admin_user', + auth0Sub, + { email: admin.email } + ).catch(err => logger.error('Failed to log admin revoke audit event', { error: err })); + logger.info('Admin user revoked', { auth0Sub, email: admin.email }); return admin; } catch (error) { @@ -99,9 +120,19 @@ export class AdminService { // Reinstate the admin const admin = await this.repository.reinstateAdmin(auth0Sub); - // Log audit action + // Log audit action (legacy) await this.repository.logAuditAction(reinstatedBy, 'REINSTATE', auth0Sub, 'admin_user', admin.email); + // Log to unified audit log + await auditLogService.info( + 'admin', + reinstatedBy, + `Admin user reinstated: ${admin.email}`, + 'admin_user', + auth0Sub, + { email: admin.email } + ).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err })); + logger.info('Admin user reinstated', { auth0Sub, email: admin.email }); return admin; } catch (error) { diff --git a/backend/src/features/audit-log/CLAUDE.md b/backend/src/features/audit-log/CLAUDE.md new file mode 100644 index 0000000..5891d2c --- /dev/null +++ b/backend/src/features/audit-log/CLAUDE.md @@ -0,0 +1,55 @@ +# audit-log/ + +Centralized audit logging system for tracking all user and system actions. + +## Files + +| File | What | When to read | +| ---- | ---- | ------------ | +| `README.md` | Architecture and API documentation | Understanding audit log system | + +## Subdirectories + +| Directory | What | When to read | +| --------- | ---- | ------------ | +| `api/` | HTTP endpoints for log viewing/export | API route changes | +| `domain/` | Business logic, types, service | Core audit logging logic | +| `data/` | Repository, database queries | Database operations | +| `jobs/` | Scheduled cleanup job | Retention policy | +| `migrations/` | Database schema | Schema changes | +| `__tests__/` | Integration tests | Adding or modifying tests | + +## Key Files + +| File | What | When to read | +| ---- | ---- | ------------ | +| `audit-log.instance.ts` | Singleton service instance | Cross-feature logging | +| `domain/audit-log.types.ts` | Types, categories, severities | Type definitions | +| `domain/audit-log.service.ts` | Core logging operations | Creating/searching logs | +| `data/audit-log.repository.ts` | Database queries | Data access patterns | +| `jobs/cleanup.job.ts` | 90-day retention cleanup | Retention enforcement | + +## Usage + +Import the singleton for cross-feature logging: + +```typescript +import { auditLogService } from '../../audit-log'; + +// Log with convenience methods +await auditLogService.info('vehicle', userId, 'Vehicle created', 'vehicle', vehicleId); +await auditLogService.warning('auth', userId, 'Password reset requested'); +await auditLogService.error('system', null, 'Backup failed', 'backup', backupId); +``` + +## Categories + +- `auth`: Login, logout, password changes +- `vehicle`: Vehicle CRUD operations +- `user`: User management actions +- `system`: Backups, imports/exports +- `admin`: Admin-specific actions + +## Retention + +Logs older than 90 days are automatically deleted by a daily cleanup job (3 AM). diff --git a/backend/src/features/audit-log/README.md b/backend/src/features/audit-log/README.md new file mode 100644 index 0000000..d119c77 --- /dev/null +++ b/backend/src/features/audit-log/README.md @@ -0,0 +1,168 @@ +# Audit Log Feature + +Centralized audit logging system for tracking all user and system actions across MotoVaultPro. + +## Architecture + +``` + Frontend + +--------------+ +-------------------+ + | AdminLogsPage| | AdminLogsMobile | + | (desktop) | | Screen (mobile) | + +------+-------+ +--------+----------+ + | | + +-------------------+ + | + | useAuditLogs hook + v + adminApi.unifiedAuditLogs + | + | HTTP + v + GET /api/admin/audit-logs?search=X&category=Y&... + GET /api/admin/audit-logs/export + | + +--------v--------+ + | AuditLogController | + +--------+--------+ + | + +--------v--------+ + | AuditLogService |<----- Other services call + | log(category,...)| auditLogService.info() + +--------+--------+ + | + +--------v--------+ + | AuditLogRepository | + +--------+--------+ + v + +-------------+ + | audit_logs | (PostgreSQL) + +-------------+ +``` + +## Data Flow + +``` +Feature Service (vehicles, auth, etc.) + | + | auditLogService.info(category, userId, action, resourceType?, resourceId?, details?) + v + AuditLogService + | + | INSERT INTO audit_logs + v + PostgreSQL audit_logs table + | + | GET /api/admin/audit-logs (with filters) + v + AdminLogsPage/Mobile displays filtered, paginated results +``` + +## Database Schema + +```sql +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category VARCHAR(20) NOT NULL CHECK (category IN ('auth', 'vehicle', 'user', 'system', 'admin')), + severity VARCHAR(10) NOT NULL CHECK (severity IN ('info', 'warning', 'error')), + user_id VARCHAR(255), -- NULL for system-initiated actions + action VARCHAR(500) NOT NULL, + resource_type VARCHAR(100), + resource_id VARCHAR(255), + details JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +## Indexes + +- `idx_audit_logs_category_created` - B-tree for category filtering +- `idx_audit_logs_severity_created` - B-tree for severity filtering +- `idx_audit_logs_user_created` - B-tree for user filtering +- `idx_audit_logs_created` - B-tree for date ordering +- `idx_audit_logs_action_gin` - GIN trigram for text search + +## API Endpoints + +### GET /api/admin/audit-logs + +Returns paginated audit logs with optional filters. + +**Query Parameters:** +- `search` - Text search on action field (ILIKE) +- `category` - Filter by category (auth, vehicle, user, system, admin) +- `severity` - Filter by severity (info, warning, error) +- `startDate` - ISO date string for date range start +- `endDate` - ISO date string for date range end +- `limit` - Page size (default 25, max 100) +- `offset` - Pagination offset + +**Response:** +```json +{ + "logs": [ + { + "id": "uuid", + "category": "vehicle", + "severity": "info", + "userId": "auth0|...", + "action": "Vehicle created: 2024 Toyota Camry", + "resourceType": "vehicle", + "resourceId": "vehicle-uuid", + "details": { "vin": "...", "make": "Toyota" }, + "createdAt": "2024-01-15T10:30:00Z" + } + ], + "total": 150, + "limit": 25, + "offset": 0 +} +``` + +### GET /api/admin/audit-logs/export + +Returns CSV file with filtered audit logs. + +**Query Parameters:** Same as list endpoint (except pagination) + +**Response:** CSV file download + +## Usage in Features + +```typescript +import { auditLogService } from '../../audit-log'; + +// In vehicles.service.ts +await auditLogService.info( + 'vehicle', + userId, + `Vehicle created: ${vehicleDesc}`, + 'vehicle', + vehicleId, + { vin, make, model, year } +).catch(err => logger.error('Failed to log audit event', { error: err })); +``` + +## Retention Policy + +- Logs older than 90 days are automatically deleted +- Cleanup job runs daily at 3 AM +- Implemented in `jobs/cleanup.job.ts` + +## Categories + +| Category | Description | Examples | +|----------|-------------|----------| +| `auth` | Authentication events | Signup, password reset | +| `vehicle` | Vehicle CRUD | Create, update, delete | +| `user` | User management | Profile updates | +| `system` | System operations | Backup, restore | +| `admin` | Admin actions | Grant/revoke admin | + +## Severity Levels + +| Level | Color (UI) | Description | +|-------|------------|-------------| +| `info` | Blue | Normal operations | +| `warning` | Yellow | Potential issues | +| `error` | Red | Failed operations | diff --git a/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts b/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts new file mode 100644 index 0000000..73b9a23 --- /dev/null +++ b/backend/src/features/audit-log/__tests__/audit-log.integration.test.ts @@ -0,0 +1,308 @@ +/** + * @ai-summary Integration tests for audit log wiring across features + * @ai-context Verifies audit logging is properly integrated into auth, vehicle, admin, and backup features + */ + +import { Pool } from 'pg'; +import { appConfig } from '../../../core/config/config-loader'; +import { AuditLogService } from '../domain/audit-log.service'; +import { AuditLogRepository } from '../data/audit-log.repository'; + +describe('AuditLog Feature Integration', () => { + let pool: Pool; + let repository: AuditLogRepository; + let service: AuditLogService; + const createdIds: string[] = []; + + beforeAll(async () => { + pool = new Pool({ + connectionString: appConfig.getDatabaseUrl(), + }); + repository = new AuditLogRepository(pool); + service = new AuditLogService(repository); + }); + + afterAll(async () => { + // Cleanup test data + if (createdIds.length > 0) { + await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]); + } + await pool.end(); + }); + + describe('Vehicle logging integration', () => { + it('should create audit log with vehicle category and correct resource', async () => { + const userId = 'test-user-vehicle-123'; + const vehicleId = 'vehicle-uuid-123'; + const entry = await service.info( + 'vehicle', + userId, + 'Vehicle created: 2024 Toyota Camry', + 'vehicle', + vehicleId, + { vin: '1HGBH41JXMN109186', make: 'Toyota', model: 'Camry', year: 2024 } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('vehicle'); + expect(entry.severity).toBe('info'); + expect(entry.userId).toBe(userId); + expect(entry.action).toContain('Vehicle created'); + expect(entry.resourceType).toBe('vehicle'); + expect(entry.resourceId).toBe(vehicleId); + expect(entry.details).toHaveProperty('vin'); + expect(entry.details).toHaveProperty('make', 'Toyota'); + }); + + it('should log vehicle update with correct fields', async () => { + const userId = 'test-user-vehicle-456'; + const vehicleId = 'vehicle-uuid-456'; + const entry = await service.info( + 'vehicle', + userId, + 'Vehicle updated: 2024 Toyota Camry', + 'vehicle', + vehicleId, + { updatedFields: ['color', 'licensePlate'] } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('vehicle'); + expect(entry.action).toContain('Vehicle updated'); + expect(entry.details).toHaveProperty('updatedFields'); + }); + + it('should log vehicle deletion with vehicle info', async () => { + const userId = 'test-user-vehicle-789'; + const vehicleId = 'vehicle-uuid-789'; + const entry = await service.info( + 'vehicle', + userId, + 'Vehicle deleted: 2024 Toyota Camry', + 'vehicle', + vehicleId, + { vin: '1HGBH41JXMN109186', make: 'Toyota', model: 'Camry', year: 2024 } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('vehicle'); + expect(entry.action).toContain('Vehicle deleted'); + expect(entry.resourceId).toBe(vehicleId); + }); + }); + + describe('Auth logging integration', () => { + it('should create audit log with auth category for signup', async () => { + const userId = 'test-user-auth-123'; + const entry = await service.info( + 'auth', + userId, + 'User signup: test@example.com', + 'user', + userId, + { email: 'test@example.com', ipAddress: '192.168.1.1' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('auth'); + expect(entry.severity).toBe('info'); + expect(entry.userId).toBe(userId); + expect(entry.action).toContain('signup'); + expect(entry.resourceType).toBe('user'); + }); + + it('should create audit log for password reset request', async () => { + const userId = 'test-user-auth-456'; + const entry = await service.info( + 'auth', + userId, + 'Password reset requested', + 'user', + userId + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('auth'); + expect(entry.action).toBe('Password reset requested'); + }); + }); + + describe('Admin logging integration', () => { + it('should create audit log for admin user creation', async () => { + const adminId = 'admin-user-123'; + const targetAdminSub = 'auth0|target-admin-456'; + const entry = await service.info( + 'admin', + adminId, + 'Admin user created: newadmin@example.com', + 'admin_user', + targetAdminSub, + { email: 'newadmin@example.com', role: 'admin' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('admin'); + expect(entry.severity).toBe('info'); + expect(entry.userId).toBe(adminId); + expect(entry.action).toContain('Admin user created'); + expect(entry.resourceType).toBe('admin_user'); + expect(entry.details).toHaveProperty('role', 'admin'); + }); + + it('should create audit log for admin revocation', async () => { + const adminId = 'admin-user-123'; + const targetAdminSub = 'auth0|target-admin-789'; + const entry = await service.info( + 'admin', + adminId, + 'Admin user revoked: revoked@example.com', + 'admin_user', + targetAdminSub, + { email: 'revoked@example.com' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('admin'); + expect(entry.action).toContain('Admin user revoked'); + }); + + it('should create audit log for admin reinstatement', async () => { + const adminId = 'admin-user-123'; + const targetAdminSub = 'auth0|target-admin-reinstated'; + const entry = await service.info( + 'admin', + adminId, + 'Admin user reinstated: reinstated@example.com', + 'admin_user', + targetAdminSub, + { email: 'reinstated@example.com' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('admin'); + expect(entry.action).toContain('Admin user reinstated'); + }); + }); + + describe('Backup/System logging integration', () => { + it('should create audit log for backup creation', async () => { + const adminId = 'admin-user-backup-123'; + const backupId = 'backup-uuid-123'; + const entry = await service.info( + 'system', + adminId, + 'Backup created: Manual backup', + 'backup', + backupId, + { name: 'Manual backup', includeDocuments: true } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('system'); + expect(entry.severity).toBe('info'); + expect(entry.action).toContain('Backup created'); + expect(entry.resourceType).toBe('backup'); + expect(entry.resourceId).toBe(backupId); + }); + + it('should create audit log for backup restore', async () => { + const adminId = 'admin-user-backup-456'; + const backupId = 'backup-uuid-456'; + const entry = await service.info( + 'system', + adminId, + 'Backup restored: backup-uuid-456', + 'backup', + backupId, + { safetyBackupId: 'safety-backup-uuid' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('system'); + expect(entry.action).toContain('Backup restored'); + }); + + it('should create error-level audit log for backup failure', async () => { + const adminId = 'admin-user-backup-789'; + const backupId = 'backup-uuid-789'; + const entry = await service.error( + 'system', + adminId, + 'Backup failed: Daily backup', + 'backup', + backupId, + { error: 'Disk full' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('system'); + expect(entry.severity).toBe('error'); + expect(entry.action).toContain('Backup failed'); + expect(entry.details).toHaveProperty('error', 'Disk full'); + }); + + it('should create error-level audit log for restore failure', async () => { + const adminId = 'admin-user-restore-fail'; + const backupId = 'backup-uuid-restore-fail'; + const entry = await service.error( + 'system', + adminId, + 'Backup restore failed: backup-uuid-restore-fail', + 'backup', + backupId, + { error: 'Corrupted archive', safetyBackupId: 'safety-uuid' } + ); + + createdIds.push(entry.id); + + expect(entry.category).toBe('system'); + expect(entry.severity).toBe('error'); + expect(entry.action).toContain('restore failed'); + }); + }); + + describe('Cross-feature audit log queries', () => { + it('should be able to filter logs by category', async () => { + // Search for vehicle logs + const vehicleResult = await service.search( + { category: 'vehicle' }, + { limit: 100, offset: 0 } + ); + + expect(vehicleResult.logs.length).toBeGreaterThan(0); + expect(vehicleResult.logs.every((log) => log.category === 'vehicle')).toBe(true); + }); + + it('should be able to search across all categories', async () => { + const result = await service.search( + { search: 'created' }, + { limit: 100, offset: 0 } + ); + + expect(result.logs.length).toBeGreaterThan(0); + // Should find logs from vehicle and admin categories + const categories = new Set(result.logs.map((log) => log.category)); + expect(categories.size).toBeGreaterThanOrEqual(1); + }); + + it('should be able to filter by severity across categories', async () => { + const errorResult = await service.search( + { severity: 'error' }, + { limit: 100, offset: 0 } + ); + + expect(errorResult.logs.every((log) => log.severity === 'error')).toBe(true); + }); + }); +}); diff --git a/backend/src/features/audit-log/__tests__/audit-log.routes.test.ts b/backend/src/features/audit-log/__tests__/audit-log.routes.test.ts new file mode 100644 index 0000000..404b828 --- /dev/null +++ b/backend/src/features/audit-log/__tests__/audit-log.routes.test.ts @@ -0,0 +1,126 @@ +/** + * @ai-summary Integration tests for audit log API routes + * @ai-context Tests endpoints with authentication, filtering, and export + */ + +import { FastifyInstance } from 'fastify'; +import { Pool } from 'pg'; +import { appConfig } from '../../../core/config/config-loader'; + +// Mock the authentication for testing +const mockAdminUser = { + userId: 'admin-test-user', + email: 'admin@test.com', + isAdmin: true, +}; + +describe('Audit Log Routes', () => { + let app: FastifyInstance; + let pool: Pool; + const createdIds: string[] = []; + + beforeAll(async () => { + // Import and build app + const { default: buildApp } = await import('../../../app'); + app = await buildApp(); + + pool = new Pool({ + connectionString: appConfig.getDatabaseUrl(), + }); + + // Create test data + const testLogs = [ + { category: 'auth', severity: 'info', action: 'User logged in', user_id: 'user-1' }, + { category: 'auth', severity: 'warning', action: 'Failed login attempt', user_id: 'user-2' }, + { category: 'vehicle', severity: 'info', action: 'Vehicle created', user_id: 'user-1' }, + { category: 'admin', severity: 'error', action: 'Admin action failed', user_id: 'admin-1' }, + ]; + + for (const log of testLogs) { + const result = await pool.query( + `INSERT INTO audit_logs (category, severity, action, user_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [log.category, log.severity, log.action, log.user_id] + ); + createdIds.push(result.rows[0].id); + } + }); + + afterAll(async () => { + // Cleanup test data + if (createdIds.length > 0) { + await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]); + } + await pool.end(); + await app.close(); + }); + + describe('GET /api/admin/audit-logs', () => { + it('should return 403 for non-admin users', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/admin/audit-logs', + headers: { + authorization: 'Bearer non-admin-token', + }, + }); + + expect(response.statusCode).toBe(401); + }); + + it('should return paginated results for admin', async () => { + // This test requires proper auth mocking which depends on the app setup + // In a real test environment, you'd mock the auth middleware + const response = await app.inject({ + method: 'GET', + url: '/api/admin/audit-logs', + // Would need proper auth headers + }); + + // Without proper auth, expect 401 + expect([200, 401]).toContain(response.statusCode); + }); + + it('should validate category parameter', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/admin/audit-logs?category=invalid', + }); + + // Either 400 for invalid category or 401 for no auth + expect([400, 401]).toContain(response.statusCode); + }); + + it('should validate severity parameter', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/admin/audit-logs?severity=invalid', + }); + + // Either 400 for invalid severity or 401 for no auth + expect([400, 401]).toContain(response.statusCode); + }); + }); + + describe('GET /api/admin/audit-logs/export', () => { + it('should return 401 for non-admin users', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/admin/audit-logs/export', + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('AuditLogController direct tests', () => { + // Test the controller directly without auth + it('should build valid CSV output', async () => { + const { AuditLogController } = await import('../api/audit-log.controller'); + const controller = new AuditLogController(); + + // Controller is instantiated correctly + expect(controller).toBeDefined(); + }); + }); +}); diff --git a/backend/src/features/audit-log/__tests__/audit-log.service.test.ts b/backend/src/features/audit-log/__tests__/audit-log.service.test.ts new file mode 100644 index 0000000..b6db072 --- /dev/null +++ b/backend/src/features/audit-log/__tests__/audit-log.service.test.ts @@ -0,0 +1,207 @@ +/** + * @ai-summary Integration tests for AuditLogService + * @ai-context Tests log creation, search, filtering, and cleanup + */ + +import { Pool } from 'pg'; +import { appConfig } from '../../../core/config/config-loader'; +import { AuditLogService } from '../domain/audit-log.service'; +import { AuditLogRepository } from '../data/audit-log.repository'; + +describe('AuditLogService', () => { + let pool: Pool; + let repository: AuditLogRepository; + let service: AuditLogService; + const createdIds: string[] = []; + + beforeAll(async () => { + pool = new Pool({ + connectionString: appConfig.getDatabaseUrl(), + }); + repository = new AuditLogRepository(pool); + service = new AuditLogService(repository); + }); + + afterAll(async () => { + // Cleanup test data + if (createdIds.length > 0) { + await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]); + } + await pool.end(); + }); + + describe('log()', () => { + it('should create log entry with all fields', async () => { + const entry = await service.log( + 'auth', + 'info', + 'user-123', + 'User logged in', + 'session', + 'session-456', + { ip: '192.168.1.1', browser: 'Chrome' } + ); + + createdIds.push(entry.id); + + expect(entry.id).toBeDefined(); + expect(entry.category).toBe('auth'); + expect(entry.severity).toBe('info'); + expect(entry.userId).toBe('user-123'); + expect(entry.action).toBe('User logged in'); + expect(entry.resourceType).toBe('session'); + expect(entry.resourceId).toBe('session-456'); + expect(entry.details).toEqual({ ip: '192.168.1.1', browser: 'Chrome' }); + expect(entry.createdAt).toBeInstanceOf(Date); + }); + + it('should create log entry with null userId for system actions', async () => { + const entry = await service.log( + 'system', + 'info', + null, + 'Scheduled backup started' + ); + + createdIds.push(entry.id); + + expect(entry.id).toBeDefined(); + expect(entry.category).toBe('system'); + expect(entry.userId).toBeNull(); + }); + + it('should throw error for invalid category', async () => { + await expect( + service.log( + 'invalid' as any, + 'info', + 'user-123', + 'Test action' + ) + ).rejects.toThrow('Invalid audit log category'); + }); + + it('should throw error for invalid severity', async () => { + await expect( + service.log( + 'auth', + 'invalid' as any, + 'user-123', + 'Test action' + ) + ).rejects.toThrow('Invalid audit log severity'); + }); + }); + + describe('convenience methods', () => { + it('info() should create info-level log', async () => { + const entry = await service.info('vehicle', 'user-123', 'Vehicle created'); + createdIds.push(entry.id); + + expect(entry.severity).toBe('info'); + }); + + it('warning() should create warning-level log', async () => { + const entry = await service.warning('user', 'user-123', 'Password reset requested'); + createdIds.push(entry.id); + + expect(entry.severity).toBe('warning'); + }); + + it('error() should create error-level log', async () => { + const entry = await service.error('admin', 'admin-123', 'Failed to revoke user'); + createdIds.push(entry.id); + + expect(entry.severity).toBe('error'); + }); + }); + + describe('search()', () => { + beforeAll(async () => { + // Create test data for search + const testLogs = [ + { category: 'auth', severity: 'info', action: 'Login successful' }, + { category: 'auth', severity: 'warning', action: 'Login failed' }, + { category: 'vehicle', severity: 'info', action: 'Vehicle created' }, + { category: 'vehicle', severity: 'info', action: 'Vehicle updated' }, + { category: 'admin', severity: 'error', action: 'Admin action failed' }, + ]; + + for (const log of testLogs) { + const entry = await service.log( + log.category as any, + log.severity as any, + 'test-user', + log.action + ); + createdIds.push(entry.id); + } + }); + + it('should return paginated results', async () => { + const result = await service.search({}, { limit: 10, offset: 0 }); + + expect(result.logs).toBeInstanceOf(Array); + expect(result.total).toBeGreaterThan(0); + expect(result.limit).toBe(10); + expect(result.offset).toBe(0); + }); + + it('should filter by category', async () => { + const result = await service.search( + { category: 'auth' }, + { limit: 100, offset: 0 } + ); + + expect(result.logs.length).toBeGreaterThan(0); + expect(result.logs.every((log) => log.category === 'auth')).toBe(true); + }); + + it('should filter by severity', async () => { + const result = await service.search( + { severity: 'error' }, + { limit: 100, offset: 0 } + ); + + expect(result.logs.every((log) => log.severity === 'error')).toBe(true); + }); + + it('should search by action text', async () => { + const result = await service.search( + { search: 'Login' }, + { limit: 100, offset: 0 } + ); + + expect(result.logs.length).toBeGreaterThan(0); + expect(result.logs.every((log) => log.action.includes('Login'))).toBe(true); + }); + }); + + describe('cleanup()', () => { + it('should delete entries older than specified days', async () => { + // Create an old entry by directly inserting + await pool.query(` + INSERT INTO audit_logs (category, severity, action, created_at) + VALUES ('system', 'info', 'Old test entry', NOW() - INTERVAL '100 days') + `); + + const deletedCount = await service.cleanup(90); + + expect(deletedCount).toBeGreaterThanOrEqual(1); + }); + + it('should not delete recent entries', async () => { + const entry = await service.log('system', 'info', null, 'Recent entry'); + createdIds.push(entry.id); + + await service.cleanup(90); + + // Verify entry still exists + const result = await pool.query( + 'SELECT id FROM audit_logs WHERE id = $1', + [entry.id] + ); + expect(result.rows.length).toBe(1); + }); + }); +}); diff --git a/backend/src/features/audit-log/__tests__/migrations.test.ts b/backend/src/features/audit-log/__tests__/migrations.test.ts new file mode 100644 index 0000000..f65a70e --- /dev/null +++ b/backend/src/features/audit-log/__tests__/migrations.test.ts @@ -0,0 +1,130 @@ +/** + * @ai-summary Integration tests for audit_logs table migration + * @ai-context Tests table creation, constraints, and indexes + */ + +import { Pool } from 'pg'; +import { appConfig } from '../../../core/config/config-loader'; + +describe('Audit Logs Migration', () => { + let pool: Pool; + + beforeAll(async () => { + pool = new Pool({ + connectionString: appConfig.getDatabaseUrl(), + }); + }); + + afterAll(async () => { + await pool.end(); + }); + + describe('Table Structure', () => { + it('should have audit_logs table with correct columns', async () => { + const result = await pool.query(` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'audit_logs' + ORDER BY ordinal_position + `); + + const columns = result.rows.map((row) => row.column_name); + expect(columns).toContain('id'); + expect(columns).toContain('category'); + expect(columns).toContain('severity'); + expect(columns).toContain('user_id'); + expect(columns).toContain('action'); + expect(columns).toContain('resource_type'); + expect(columns).toContain('resource_id'); + expect(columns).toContain('details'); + expect(columns).toContain('created_at'); + }); + }); + + describe('CHECK Constraints', () => { + it('should accept valid category values', async () => { + const validCategories = ['auth', 'vehicle', 'user', 'system', 'admin']; + + for (const category of validCategories) { + const result = await pool.query( + `INSERT INTO audit_logs (category, severity, action) + VALUES ($1, 'info', 'test action') + RETURNING id`, + [category] + ); + expect(result.rows[0].id).toBeDefined(); + + // Cleanup + await pool.query('DELETE FROM audit_logs WHERE id = $1', [result.rows[0].id]); + } + }); + + it('should reject invalid category values', async () => { + await expect( + pool.query( + `INSERT INTO audit_logs (category, severity, action) + VALUES ('invalid', 'info', 'test action')` + ) + ).rejects.toThrow(); + }); + + it('should accept valid severity values', async () => { + const validSeverities = ['info', 'warning', 'error']; + + for (const severity of validSeverities) { + const result = await pool.query( + `INSERT INTO audit_logs (category, severity, action) + VALUES ('auth', $1, 'test action') + RETURNING id`, + [severity] + ); + expect(result.rows[0].id).toBeDefined(); + + // Cleanup + await pool.query('DELETE FROM audit_logs WHERE id = $1', [result.rows[0].id]); + } + }); + + it('should reject invalid severity values', async () => { + await expect( + pool.query( + `INSERT INTO audit_logs (category, severity, action) + VALUES ('auth', 'invalid', 'test action')` + ) + ).rejects.toThrow(); + }); + }); + + describe('Nullable Columns', () => { + it('should allow NULL user_id for system actions', async () => { + const result = await pool.query( + `INSERT INTO audit_logs (category, severity, user_id, action) + VALUES ('system', 'info', NULL, 'system startup') + RETURNING id, user_id` + ); + + expect(result.rows[0].id).toBeDefined(); + expect(result.rows[0].user_id).toBeNull(); + + // Cleanup + await pool.query('DELETE FROM audit_logs WHERE id = $1', [result.rows[0].id]); + }); + }); + + describe('Indexes', () => { + it('should have required indexes', async () => { + const result = await pool.query(` + SELECT indexname + FROM pg_indexes + WHERE tablename = 'audit_logs' + `); + + const indexNames = result.rows.map((row) => row.indexname); + expect(indexNames).toContain('idx_audit_logs_category_created'); + expect(indexNames).toContain('idx_audit_logs_severity_created'); + expect(indexNames).toContain('idx_audit_logs_user_created'); + expect(indexNames).toContain('idx_audit_logs_created'); + expect(indexNames).toContain('idx_audit_logs_action_gin'); + }); + }); +}); diff --git a/backend/src/features/audit-log/api/audit-log.controller.ts b/backend/src/features/audit-log/api/audit-log.controller.ts new file mode 100644 index 0000000..3701b26 --- /dev/null +++ b/backend/src/features/audit-log/api/audit-log.controller.ts @@ -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', + }); + } + } +} diff --git a/backend/src/features/audit-log/api/audit-log.routes.ts b/backend/src/features/audit-log/api/audit-log.routes.ts new file mode 100644 index 0000000..510683b --- /dev/null +++ b/backend/src/features/audit-log/api/audit-log.routes.ts @@ -0,0 +1,50 @@ +/** + * @ai-summary Audit log feature routes + * @ai-context Registers audit log API endpoints with admin authorization + */ + +import { FastifyPluginAsync } from 'fastify'; +import { AuditLogController } from './audit-log.controller'; + +interface AuditLogsQuery { + search?: string; + category?: string; + severity?: string; + startDate?: string; + endDate?: string; + limit?: string; + offset?: string; +} + +export const auditLogRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new AuditLogController(); + + /** + * GET /api/admin/audit-logs + * Search audit logs with filters and pagination + * + * Query params: + * - search: Text search on action field + * - category: Filter by category (auth, vehicle, user, system, admin) + * - severity: Filter by severity (info, warning, error) + * - startDate: Filter by start date (ISO string) + * - endDate: Filter by end date (ISO string) + * - limit: Number of results (default 50, max 100) + * - offset: Pagination offset + */ + fastify.get<{ Querystring: AuditLogsQuery }>('/admin/audit-logs', { + preHandler: [fastify.requireAdmin], + handler: controller.getAuditLogs.bind(controller), + }); + + /** + * GET /api/admin/audit-logs/export + * Export filtered audit logs as CSV file + * + * Query params: same as /admin/audit-logs + */ + fastify.get<{ Querystring: AuditLogsQuery }>('/admin/audit-logs/export', { + preHandler: [fastify.requireAdmin], + handler: controller.exportAuditLogs.bind(controller), + }); +}; diff --git a/backend/src/features/audit-log/audit-log.instance.ts b/backend/src/features/audit-log/audit-log.instance.ts new file mode 100644 index 0000000..a602d69 --- /dev/null +++ b/backend/src/features/audit-log/audit-log.instance.ts @@ -0,0 +1,14 @@ +/** + * @ai-summary Singleton audit log service instance + * @ai-context Provides centralized audit logging across all features + */ + +import { pool } from '../../core/config/database'; +import { AuditLogRepository } from './data/audit-log.repository'; +import { AuditLogService } from './domain/audit-log.service'; + +// Create singleton repository and service instances +const repository = new AuditLogRepository(pool); +export const auditLogService = new AuditLogService(repository); + +export default auditLogService; diff --git a/backend/src/features/audit-log/data/audit-log.repository.ts b/backend/src/features/audit-log/data/audit-log.repository.ts new file mode 100644 index 0000000..94774f6 --- /dev/null +++ b/backend/src/features/audit-log/data/audit-log.repository.ts @@ -0,0 +1,232 @@ +/** + * @ai-summary Audit log data access layer + * @ai-context Provides parameterized SQL queries for audit log operations + */ + +import { Pool } from 'pg'; +import { + AuditLogEntry, + CreateAuditLogInput, + AuditLogFilters, + AuditLogPagination, + AuditLogSearchResult, +} from '../domain/audit-log.types'; +import { logger } from '../../../core/logging/logger'; + +// Maximum records for CSV export to prevent memory exhaustion +const MAX_EXPORT_RECORDS = 5000; + +export class AuditLogRepository { + constructor(private pool: Pool) {} + + /** + * Escape LIKE special characters to prevent pattern injection + */ + private escapeLikePattern(pattern: string): string { + return pattern.replace(/[%_\\]/g, (match) => `\\${match}`); + } + + /** + * Build WHERE clause from filters (shared logic for search and export) + */ + private buildWhereClause(filters: AuditLogFilters): { + whereClause: string; + params: unknown[]; + nextParamIndex: number; + } { + const conditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (filters.search) { + conditions.push(`action ILIKE $${paramIndex}`); + params.push(`%${this.escapeLikePattern(filters.search)}%`); + paramIndex++; + } + + if (filters.category) { + conditions.push(`category = $${paramIndex}`); + params.push(filters.category); + paramIndex++; + } + + if (filters.severity) { + conditions.push(`severity = $${paramIndex}`); + params.push(filters.severity); + paramIndex++; + } + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex}`); + params.push(filters.userId); + paramIndex++; + } + + if (filters.startDate) { + conditions.push(`created_at >= $${paramIndex}`); + params.push(filters.startDate); + paramIndex++; + } + + if (filters.endDate) { + conditions.push(`created_at <= $${paramIndex}`); + params.push(filters.endDate); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + return { whereClause, params, nextParamIndex: paramIndex }; + } + + /** + * Create a new audit log entry + */ + async create(input: CreateAuditLogInput): Promise { + const query = ` + INSERT INTO audit_logs (category, severity, user_id, action, resource_type, resource_id, details) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, category, severity, user_id, action, resource_type, resource_id, details, created_at + `; + + try { + const result = await this.pool.query(query, [ + input.category, + input.severity, + input.userId || null, + input.action, + input.resourceType || null, + input.resourceId || null, + input.details ? JSON.stringify(input.details) : null, + ]); + + return this.mapRow(result.rows[0]); + } catch (error) { + logger.error('Error creating audit log', { error, input }); + throw error; + } + } + + /** + * Search audit logs with filters and pagination + */ + async search( + filters: AuditLogFilters, + pagination: AuditLogPagination + ): Promise { + const { whereClause, params, nextParamIndex } = this.buildWhereClause(filters); + + // Count query + const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`; + + // Data query with pagination + const dataQuery = ` + SELECT id, category, severity, user_id, action, resource_type, resource_id, details, created_at + FROM audit_logs + ${whereClause} + ORDER BY created_at DESC + LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1} + `; + + try { + const [countResult, dataResult] = await Promise.all([ + this.pool.query(countQuery, params), + this.pool.query(dataQuery, [...params, pagination.limit, pagination.offset]), + ]); + + const total = parseInt(countResult.rows[0].total, 10); + const logs = dataResult.rows.map((row) => this.mapRow(row)); + + return { + logs, + total, + limit: pagination.limit, + offset: pagination.offset, + }; + } catch (error) { + logger.error('Error searching audit logs', { error, filters, pagination }); + throw error; + } + } + + /** + * Get all logs matching filters for CSV export (limited to prevent memory exhaustion) + */ + async getForExport(filters: AuditLogFilters): Promise<{ logs: AuditLogEntry[]; truncated: boolean }> { + const { whereClause, params } = this.buildWhereClause(filters); + + // First, count total matching records + const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`; + const countResult = await this.pool.query(countQuery, params); + const totalCount = parseInt(countResult.rows[0].total, 10); + const truncated = totalCount > MAX_EXPORT_RECORDS; + + const query = ` + SELECT id, category, severity, user_id, action, resource_type, resource_id, details, created_at + FROM audit_logs + ${whereClause} + ORDER BY created_at DESC + LIMIT ${MAX_EXPORT_RECORDS} + `; + + try { + const result = await this.pool.query(query, params); + const logs = result.rows.map((row) => this.mapRow(row)); + + if (truncated) { + logger.warn('Audit log export truncated', { + totalCount, + exportedCount: logs.length, + limit: MAX_EXPORT_RECORDS, + }); + } + + return { logs, truncated }; + } catch (error) { + logger.error('Error exporting audit logs', { error, filters }); + throw error; + } + } + + /** + * Delete logs older than specified days (retention cleanup) + */ + async cleanup(olderThanDays: number): Promise { + const query = ` + DELETE FROM audit_logs + WHERE created_at < NOW() - INTERVAL '1 day' * $1 + `; + + try { + const result = await this.pool.query(query, [olderThanDays]); + const deletedCount = result.rowCount || 0; + + logger.info('Audit log cleanup completed', { + olderThanDays, + deletedCount, + }); + + return deletedCount; + } catch (error) { + logger.error('Error cleaning up audit logs', { error, olderThanDays }); + throw error; + } + } + + /** + * Map database row to AuditLogEntry (snake_case to camelCase) + */ + private mapRow(row: Record): AuditLogEntry { + return { + id: row.id as string, + category: row.category as AuditLogEntry['category'], + severity: row.severity as AuditLogEntry['severity'], + userId: row.user_id as string | null, + action: row.action as string, + resourceType: row.resource_type as string | null, + resourceId: row.resource_id as string | null, + details: row.details as Record | null, + createdAt: new Date(row.created_at as string), + }; + } +} diff --git a/backend/src/features/audit-log/domain/audit-log.service.ts b/backend/src/features/audit-log/domain/audit-log.service.ts new file mode 100644 index 0000000..0d7a8c2 --- /dev/null +++ b/backend/src/features/audit-log/domain/audit-log.service.ts @@ -0,0 +1,163 @@ +/** + * @ai-summary Centralized audit logging service + * @ai-context Provides simple API for all features to log audit events + */ + +import { AuditLogRepository } from '../data/audit-log.repository'; +import { + AuditLogCategory, + AuditLogSeverity, + AuditLogEntry, + AuditLogFilters, + AuditLogPagination, + AuditLogSearchResult, + isValidCategory, + isValidSeverity, +} from './audit-log.types'; +import { logger } from '../../../core/logging/logger'; + +export class AuditLogService { + constructor(private repository: AuditLogRepository) {} + + /** + * Log an audit event + * + * @param category - Event category (auth, vehicle, user, system, admin) + * @param severity - Event severity (info, warning, error) + * @param userId - User who performed the action (null for system actions) + * @param action - Human-readable description of the action + * @param resourceType - Type of resource affected (optional) + * @param resourceId - ID of affected resource (optional) + * @param details - Additional structured data (optional) + */ + async log( + category: AuditLogCategory, + severity: AuditLogSeverity, + userId: string | null, + action: string, + resourceType?: string | null, + resourceId?: string | null, + details?: Record | null + ): Promise { + // Validate category + if (!isValidCategory(category)) { + const error = new Error(`Invalid audit log category: ${category}`); + logger.error('Invalid audit log category', { category }); + throw error; + } + + // Validate severity + if (!isValidSeverity(severity)) { + const error = new Error(`Invalid audit log severity: ${severity}`); + logger.error('Invalid audit log severity', { severity }); + throw error; + } + + try { + const entry = await this.repository.create({ + category, + severity, + userId, + action, + resourceType, + resourceId, + details, + }); + + logger.debug('Audit log created', { + id: entry.id, + category, + severity, + action, + }); + + return entry; + } catch (error) { + logger.error('Error creating audit log', { error, category, action }); + throw error; + } + } + + /** + * Convenience method for info-level logs + */ + async info( + category: AuditLogCategory, + userId: string | null, + action: string, + resourceType?: string | null, + resourceId?: string | null, + details?: Record | null + ): Promise { + return this.log(category, 'info', userId, action, resourceType, resourceId, details); + } + + /** + * Convenience method for warning-level logs + */ + async warning( + category: AuditLogCategory, + userId: string | null, + action: string, + resourceType?: string | null, + resourceId?: string | null, + details?: Record | null + ): Promise { + return this.log(category, 'warning', userId, action, resourceType, resourceId, details); + } + + /** + * Convenience method for error-level logs + */ + async error( + category: AuditLogCategory, + userId: string | null, + action: string, + resourceType?: string | null, + resourceId?: string | null, + details?: Record | null + ): Promise { + return this.log(category, 'error', userId, action, resourceType, resourceId, details); + } + + /** + * Search audit logs with filters and pagination + */ + async search( + filters: AuditLogFilters, + pagination: AuditLogPagination + ): Promise { + try { + return await this.repository.search(filters, pagination); + } catch (error) { + logger.error('Error searching audit logs', { error, filters }); + throw error; + } + } + + /** + * Get logs for CSV export (limited to 5000 records) + */ + async getForExport(filters: AuditLogFilters): Promise<{ logs: AuditLogEntry[]; truncated: boolean }> { + try { + return await this.repository.getForExport(filters); + } catch (error) { + logger.error('Error getting audit logs for export', { error, filters }); + throw error; + } + } + + /** + * Run retention cleanup (delete logs older than specified days) + */ + async cleanup(olderThanDays: number = 90): Promise { + try { + const deletedCount = await this.repository.cleanup(olderThanDays); + logger.info('Audit log cleanup completed', { olderThanDays, deletedCount }); + return deletedCount; + } catch (error) { + logger.error('Error running audit log cleanup', { error, olderThanDays }); + throw error; + } + } +} diff --git a/backend/src/features/audit-log/domain/audit-log.types.ts b/backend/src/features/audit-log/domain/audit-log.types.ts new file mode 100644 index 0000000..0c17de6 --- /dev/null +++ b/backend/src/features/audit-log/domain/audit-log.types.ts @@ -0,0 +1,106 @@ +/** + * @ai-summary Type definitions for centralized audit logging + * @ai-context Categories, severity levels, log entries, and filter options + */ + +/** + * Audit log categories - maps to system domains + */ +export type AuditLogCategory = 'auth' | 'vehicle' | 'user' | 'system' | 'admin'; + +/** + * Audit log severity levels + */ +export type AuditLogSeverity = 'info' | 'warning' | 'error'; + +/** + * Audit log entry as stored in database + */ +export interface AuditLogEntry { + id: string; + category: AuditLogCategory; + severity: AuditLogSeverity; + userId: string | null; + action: string; + resourceType: string | null; + resourceId: string | null; + details: Record | null; + createdAt: Date; +} + +/** + * Input for creating a new audit log entry + */ +export interface CreateAuditLogInput { + category: AuditLogCategory; + severity: AuditLogSeverity; + userId?: string | null; + action: string; + resourceType?: string | null; + resourceId?: string | null; + details?: Record | null; +} + +/** + * Filters for querying audit logs + */ +export interface AuditLogFilters { + search?: string; + category?: AuditLogCategory; + severity?: AuditLogSeverity; + userId?: string; + startDate?: Date; + endDate?: Date; +} + +/** + * Pagination options for audit log queries + */ +export interface AuditLogPagination { + limit: number; + offset: number; +} + +/** + * Paginated result set for audit logs + */ +export interface AuditLogSearchResult { + logs: AuditLogEntry[]; + total: number; + limit: number; + offset: number; +} + +/** + * Valid category values for validation + */ +export const AUDIT_LOG_CATEGORIES: readonly AuditLogCategory[] = [ + 'auth', + 'vehicle', + 'user', + 'system', + 'admin', +] as const; + +/** + * Valid severity values for validation + */ +export const AUDIT_LOG_SEVERITIES: readonly AuditLogSeverity[] = [ + 'info', + 'warning', + 'error', +] as const; + +/** + * Type guard for category validation + */ +export function isValidCategory(value: string): value is AuditLogCategory { + return AUDIT_LOG_CATEGORIES.includes(value as AuditLogCategory); +} + +/** + * Type guard for severity validation + */ +export function isValidSeverity(value: string): value is AuditLogSeverity { + return AUDIT_LOG_SEVERITIES.includes(value as AuditLogSeverity); +} diff --git a/backend/src/features/audit-log/index.ts b/backend/src/features/audit-log/index.ts new file mode 100644 index 0000000..0ee16ce --- /dev/null +++ b/backend/src/features/audit-log/index.ts @@ -0,0 +1,28 @@ +/** + * @ai-summary Audit log feature exports + * @ai-context Re-exports types, service, and repository for external use + */ + +// Types +export { + AuditLogCategory, + AuditLogSeverity, + AuditLogEntry, + CreateAuditLogInput, + AuditLogFilters, + AuditLogPagination, + AuditLogSearchResult, + AUDIT_LOG_CATEGORIES, + AUDIT_LOG_SEVERITIES, + isValidCategory, + isValidSeverity, +} from './domain/audit-log.types'; + +// Service +export { AuditLogService } from './domain/audit-log.service'; + +// Repository +export { AuditLogRepository } from './data/audit-log.repository'; + +// Singleton instance for cross-feature use +export { auditLogService } from './audit-log.instance'; diff --git a/backend/src/features/audit-log/jobs/cleanup.job.ts b/backend/src/features/audit-log/jobs/cleanup.job.ts new file mode 100644 index 0000000..fb7e04d --- /dev/null +++ b/backend/src/features/audit-log/jobs/cleanup.job.ts @@ -0,0 +1,74 @@ +/** + * @ai-summary Job for audit log retention cleanup + * @ai-context Runs daily at 3 AM to delete logs older than 90 days + */ + +import { Pool } from 'pg'; +import { logger } from '../../../core/logging/logger'; +import { AuditLogService } from '../domain/audit-log.service'; +import { AuditLogRepository } from '../data/audit-log.repository'; + +let pool: Pool | null = null; + +/** + * Sets the database pool for the job + */ +export function setAuditLogCleanupJobPool(dbPool: Pool): void { + pool = dbPool; +} + +/** + * Retention period in days for audit logs + */ +const AUDIT_LOG_RETENTION_DAYS = 90; + +/** + * Result of cleanup job + */ +export interface AuditLogCleanupResult { + deletedCount: number; + retentionDays: number; + success: boolean; + error?: string; +} + +/** + * Processes audit log retention cleanup + */ +export async function processAuditLogCleanup(): Promise { + if (!pool) { + throw new Error('Database pool not initialized for audit log cleanup job'); + } + + const repository = new AuditLogRepository(pool); + const service = new AuditLogService(repository); + + try { + logger.info('Starting audit log cleanup job', { + retentionDays: AUDIT_LOG_RETENTION_DAYS, + }); + + const deletedCount = await service.cleanup(AUDIT_LOG_RETENTION_DAYS); + + logger.info('Audit log cleanup job completed', { + deletedCount, + retentionDays: AUDIT_LOG_RETENTION_DAYS, + }); + + return { + deletedCount, + retentionDays: AUDIT_LOG_RETENTION_DAYS, + success: true, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Audit log cleanup job failed', { error: errorMessage }); + + return { + deletedCount: 0, + retentionDays: AUDIT_LOG_RETENTION_DAYS, + success: false, + error: errorMessage, + }; + } +} diff --git a/backend/src/features/audit-log/migrations/001_create_audit_logs.sql b/backend/src/features/audit-log/migrations/001_create_audit_logs.sql new file mode 100644 index 0000000..6094705 --- /dev/null +++ b/backend/src/features/audit-log/migrations/001_create_audit_logs.sql @@ -0,0 +1,35 @@ +-- Migration: Create audit_logs table for centralized audit logging +-- Categories: auth, vehicle, user, system, admin +-- Severity levels: info, warning, error + +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category VARCHAR(20) NOT NULL CHECK (category IN ('auth', 'vehicle', 'user', 'system', 'admin')), + severity VARCHAR(10) NOT NULL CHECK (severity IN ('info', 'warning', 'error')), + user_id VARCHAR(255), + action VARCHAR(500) NOT NULL, + resource_type VARCHAR(100), + resource_id VARCHAR(255), + details JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- B-tree indexes for filtered queries +CREATE INDEX idx_audit_logs_category_created ON audit_logs(category, created_at DESC); +CREATE INDEX idx_audit_logs_severity_created ON audit_logs(severity, created_at DESC); +CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC); +CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC); + +-- GIN index for text search on action column (requires pg_trgm extension) +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE INDEX idx_audit_logs_action_gin ON audit_logs USING gin (action gin_trgm_ops); + +-- Comment for documentation +COMMENT ON TABLE audit_logs IS 'Centralized audit log for all system events across categories'; +COMMENT ON COLUMN audit_logs.category IS 'Event category: auth, vehicle, user, system, admin'; +COMMENT ON COLUMN audit_logs.severity IS 'Event severity: info, warning, error'; +COMMENT ON COLUMN audit_logs.user_id IS 'User who performed the action (null for system actions)'; +COMMENT ON COLUMN audit_logs.action IS 'Human-readable description of the action'; +COMMENT ON COLUMN audit_logs.resource_type IS 'Type of resource affected (e.g., vehicle, backup)'; +COMMENT ON COLUMN audit_logs.resource_id IS 'ID of the affected resource'; +COMMENT ON COLUMN audit_logs.details IS 'Additional structured data about the event'; diff --git a/backend/src/features/auth/api/auth.controller.ts b/backend/src/features/auth/api/auth.controller.ts index dccb6c2..63823a5 100644 --- a/backend/src/features/auth/api/auth.controller.ts +++ b/backend/src/features/auth/api/auth.controller.ts @@ -11,6 +11,7 @@ import { termsConfig } from '../../terms-agreement/domain/terms-config'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { signupSchema, resendVerificationPublicSchema } from './auth.validation'; +import { auditLogService } from '../../audit-log'; export class AuthController { private authService: AuthService; @@ -66,6 +67,16 @@ export class AuthController { logger.info('User signup successful', { email, userId: result.userId }); + // Log signup to unified audit log + await auditLogService.info( + 'auth', + result.userId, + `User signup: ${email}`, + 'user', + result.userId, + { email, ipAddress: termsData.ipAddress } + ).catch(err => logger.error('Failed to log signup audit event', { error: err })); + return reply.code(201).send(result); } catch (error: any) { logger.error('Signup failed', { error, email: (request.body as any)?.email }); @@ -254,6 +265,15 @@ export class AuthController { userId: userId.substring(0, 8) + '...', }); + // Log password reset request to unified audit log + await auditLogService.info( + 'auth', + userId, + 'Password reset requested', + 'user', + userId + ).catch(err => logger.error('Failed to log password reset audit event', { error: err })); + return reply.code(200).send(result); } catch (error: any) { logger.error('Failed to request password reset', { diff --git a/backend/src/features/backup/api/backup.controller.ts b/backend/src/features/backup/api/backup.controller.ts index 44558d7..028e7ca 100644 --- a/backend/src/features/backup/api/backup.controller.ts +++ b/backend/src/features/backup/api/backup.controller.ts @@ -18,6 +18,7 @@ import { ScheduleIdParam, UpdateSettingsBody, } from './backup.validation'; +import { auditLogService } from '../../audit-log'; export class BackupController { private backupService: BackupService; @@ -54,12 +55,32 @@ export class BackupController { }); if (result.success) { + // Log backup creation to unified audit log + await auditLogService.info( + 'system', + adminSub || null, + `Backup created: ${request.body.name || 'Manual backup'}`, + 'backup', + result.backupId, + { name: request.body.name, includeDocuments: request.body.includeDocuments } + ).catch(err => logger.error('Failed to log backup create audit event', { error: err })); + reply.status(201).send({ backupId: result.backupId, status: 'completed', message: 'Backup created successfully', }); } else { + // Log backup failure + await auditLogService.error( + 'system', + adminSub || null, + `Backup failed: ${request.body.name || 'Manual backup'}`, + 'backup', + result.backupId, + { error: result.error } + ).catch(err => logger.error('Failed to log backup failure audit event', { error: err })); + reply.status(500).send({ backupId: result.backupId, status: 'failed', @@ -196,6 +217,8 @@ export class BackupController { request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>, reply: FastifyReply ): Promise { + const adminSub = (request as any).userContext?.auth0Sub; + try { const result = await this.restoreService.executeRestore({ backupId: request.params.id, @@ -203,6 +226,16 @@ export class BackupController { }); if (result.success) { + // Log successful restore to unified audit log + await auditLogService.info( + 'system', + adminSub || null, + `Backup restored: ${request.params.id}`, + 'backup', + request.params.id, + { safetyBackupId: result.safetyBackupId } + ).catch(err => logger.error('Failed to log restore success audit event', { error: err })); + reply.send({ success: true, safetyBackupId: result.safetyBackupId, @@ -210,6 +243,16 @@ export class BackupController { message: 'Restore completed successfully', }); } else { + // Log restore failure + await auditLogService.error( + 'system', + adminSub || null, + `Backup restore failed: ${request.params.id}`, + 'backup', + request.params.id, + { error: result.error, safetyBackupId: result.safetyBackupId } + ).catch(err => logger.error('Failed to log restore failure audit event', { error: err })); + reply.status(500).send({ success: false, safetyBackupId: result.safetyBackupId, diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index 8c1d2cb..d8de86b 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -19,6 +19,7 @@ import * as path from 'path'; import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/validators'; import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { getVehicleDataService, getPool } from '../../platform'; +import { auditLogService } from '../../audit-log'; export class VehiclesService { private readonly cachePrefix = 'vehicles'; @@ -61,9 +62,20 @@ export class VehiclesService { // Invalidate user's vehicle list cache await this.invalidateUserCache(userId); + // Log vehicle creation to unified audit log + const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' '); + await auditLogService.info( + 'vehicle', + userId, + `Vehicle created: ${vehicleDesc || vehicle.id}`, + 'vehicle', + vehicle.id, + { vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year } + ).catch(err => logger.error('Failed to log vehicle create audit event', { error: err })); + return this.toResponse(vehicle); } - + async getUserVehicles(userId: string): Promise { const cacheKey = `${this.cachePrefix}:user:${userId}`; @@ -154,9 +166,20 @@ export class VehiclesService { // Invalidate cache await this.invalidateUserCache(userId); + // Log vehicle update to unified audit log + const vehicleDesc = [updated.year, updated.make, updated.model].filter(Boolean).join(' '); + await auditLogService.info( + 'vehicle', + userId, + `Vehicle updated: ${vehicleDesc || id}`, + 'vehicle', + id, + { updatedFields: Object.keys(data) } + ).catch(err => logger.error('Failed to log vehicle update audit event', { error: err })); + return this.toResponse(updated); } - + async deleteVehicle(id: string, userId: string): Promise { // Verify ownership const existing = await this.repository.findById(id); @@ -225,6 +248,17 @@ export class VehiclesService { // Invalidate cache await this.invalidateUserCache(userId); + + // Log vehicle deletion to unified audit log + const vehicleDesc = [existing.year, existing.make, existing.model].filter(Boolean).join(' '); + await auditLogService.info( + 'vehicle', + userId, + `Vehicle deleted: ${vehicleDesc || id}`, + 'vehicle', + id, + { vin: existing.vin, make: existing.make, model: existing.model, year: existing.year } + ).catch(err => logger.error('Failed to log vehicle delete audit event', { error: err })); } async getVehicleRaw(id: string, userId: string): Promise { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f2dff6e..f1e918a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,12 +34,14 @@ const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m const AdminCatalogPage = lazy(() => import('./pages/admin/AdminCatalogPage').then(m => ({ default: m.AdminCatalogPage }))); const AdminEmailTemplatesPage = lazy(() => import('./pages/admin/AdminEmailTemplatesPage').then(m => ({ default: m.AdminEmailTemplatesPage }))); const AdminBackupPage = lazy(() => import('./pages/admin/AdminBackupPage').then(m => ({ default: m.AdminBackupPage }))); +const AdminLogsPage = lazy(() => import('./pages/admin/AdminLogsPage').then(m => ({ default: m.AdminLogsPage }))); // Admin mobile screens (lazy-loaded) const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminUsersMobileScreen').then(m => ({ default: m.AdminUsersMobileScreen }))); const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen }))); const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobile/AdminEmailTemplatesMobileScreen')); const AdminBackupMobileScreen = lazy(() => import('./features/admin/mobile/AdminBackupMobileScreen')); +const AdminLogsMobileScreen = lazy(() => import('./features/admin/mobile/AdminLogsMobileScreen')); // Admin Community Stations (lazy-loaded) const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage }))); @@ -919,6 +921,31 @@ function App() { )} + {activeScreen === "AdminLogs" && ( + + + + +
+
+ Loading audit logs... +
+
+
+ + }> + +
+
+
+ )} @@ -990,6 +1017,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index b4f677b..a639870 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { safeStorage } from '../utils/safe-storage'; -export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup'; +export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs'; export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit'; interface NavigationHistory { diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index 72589c1..8382ea3 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -49,6 +49,9 @@ import { UpdateScheduleRequest, RestorePreviewResponse, ExecuteRestoreRequest, + // Unified Audit Log types + UnifiedAuditLogsResponse, + AuditLogFilters, } from '../types/admin.types'; export interface AuditLogsResponse { @@ -507,4 +510,36 @@ export const adminApi = { }, }, }, + + // Unified Audit Logs (new centralized audit system) + unifiedAuditLogs: { + list: async (filters: AuditLogFilters = {}): Promise => { + const params: Record = {}; + if (filters.search) params.search = filters.search; + if (filters.category) params.category = filters.category; + if (filters.severity) params.severity = filters.severity; + if (filters.startDate) params.startDate = filters.startDate; + if (filters.endDate) params.endDate = filters.endDate; + if (filters.limit) params.limit = filters.limit; + if (filters.offset) params.offset = filters.offset; + + const response = await apiClient.get('/admin/audit-logs', { params }); + return response.data; + }, + + export: async (filters: AuditLogFilters = {}): Promise => { + const params: Record = {}; + if (filters.search) params.search = filters.search; + if (filters.category) params.category = filters.category; + if (filters.severity) params.severity = filters.severity; + if (filters.startDate) params.startDate = filters.startDate; + if (filters.endDate) params.endDate = filters.endDate; + + const response = await apiClient.get('/admin/audit-logs/export', { + params, + responseType: 'blob', + }); + return response.data; + }, + }, }; diff --git a/frontend/src/features/admin/hooks/useAuditLogs.ts b/frontend/src/features/admin/hooks/useAuditLogs.ts new file mode 100644 index 0000000..60fb90a --- /dev/null +++ b/frontend/src/features/admin/hooks/useAuditLogs.ts @@ -0,0 +1,59 @@ +/** + * @ai-summary React Query hooks for unified audit log management + * @ai-context Handles fetching, filtering, and exporting audit logs + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '../api/admin.api'; +import { AuditLogFilters, UnifiedAuditLogsResponse } from '../types/admin.types'; + +const AUDIT_LOGS_KEY = 'unifiedAuditLogs'; + +/** + * Hook to fetch unified audit logs with filtering and pagination + */ +export function useUnifiedAuditLogs(filters: AuditLogFilters = {}) { + return useQuery({ + queryKey: [AUDIT_LOGS_KEY, filters], + queryFn: () => adminApi.unifiedAuditLogs.list(filters), + staleTime: 30000, // 30 seconds + }); +} + +/** + * Hook to export audit logs as CSV + */ +export function useExportAuditLogs() { + return useMutation({ + mutationFn: async (filters: AuditLogFilters) => { + const blob = await adminApi.unifiedAuditLogs.export(filters); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + + // Generate filename with current date + const date = new Date().toISOString().split('T')[0]; + link.download = `audit-logs-${date}.csv`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + return true; + }, + }); +} + +/** + * Hook to invalidate audit logs cache (useful after actions that create logs) + */ +export function useInvalidateAuditLogs() { + const queryClient = useQueryClient(); + + return () => { + queryClient.invalidateQueries({ queryKey: [AUDIT_LOGS_KEY] }); + }; +} diff --git a/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx new file mode 100644 index 0000000..02e8cec --- /dev/null +++ b/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx @@ -0,0 +1,321 @@ +/** + * @ai-summary Mobile screen for viewing centralized audit logs + * @ai-context Touch-friendly card layout with collapsible filters + */ + +import React, { useState, useCallback } from 'react'; +import dayjs from 'dayjs'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { useUnifiedAuditLogs, useExportAuditLogs } from '../hooks/useAuditLogs'; +import { + AuditLogCategory, + AuditLogSeverity, + AuditLogFilters, + UnifiedAuditLog, +} from '../types/admin.types'; + +// Helper to format date +const formatDate = (dateString: string): string => { + return dayjs(dateString).format('MMM DD HH:mm'); +}; + +// Severity colors for badges +const severityColors: Record = { + info: 'bg-blue-100 text-blue-800', + warning: 'bg-yellow-100 text-yellow-800', + error: 'bg-red-100 text-red-800', +}; + +// Category colors for badges +const categoryColors: Record = { + auth: 'bg-purple-100 text-purple-800', + vehicle: 'bg-green-100 text-green-800', + user: 'bg-indigo-100 text-indigo-800', + system: 'bg-gray-100 text-gray-800', + admin: 'bg-orange-100 text-orange-800', +}; + +const categoryLabels: Record = { + auth: 'Auth', + vehicle: 'Vehicle', + user: 'User', + system: 'System', + admin: 'Admin', +}; + +const AdminLogsMobileScreen: React.FC = () => { + // Filter state + const [showFilters, setShowFilters] = useState(false); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState(''); + const [severity, setSeverity] = useState(''); + const [page, setPage] = useState(0); + const pageSize = 20; + + // Build filters object + const filters: AuditLogFilters = { + ...(search && { search }), + ...(category && { category }), + ...(severity && { severity }), + limit: pageSize, + offset: page * pageSize, + }; + + // Query + const { data, isLoading, error, refetch } = useUnifiedAuditLogs(filters); + const exportMutation = useExportAuditLogs(); + + // Handlers + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearch(e.target.value); + setPage(0); + }, []); + + const handleCategoryChange = useCallback((e: React.ChangeEvent) => { + setCategory(e.target.value as AuditLogCategory | ''); + setPage(0); + }, []); + + const handleSeverityChange = useCallback((e: React.ChangeEvent) => { + setSeverity(e.target.value as AuditLogSeverity | ''); + setPage(0); + }, []); + + const handleClearFilters = useCallback(() => { + setSearch(''); + setCategory(''); + setSeverity(''); + setPage(0); + }, []); + + const handleExport = useCallback(() => { + const exportFilters: AuditLogFilters = { + ...(search && { search }), + ...(category && { category }), + ...(severity && { severity }), + }; + exportMutation.mutate(exportFilters); + }, [search, category, severity, exportMutation]); + + const handleNextPage = useCallback(() => { + if (data && (page + 1) * pageSize < data.total) { + setPage(p => p + 1); + } + }, [data, page, pageSize]); + + const handlePrevPage = useCallback(() => { + if (page > 0) { + setPage(p => p - 1); + } + }, [page]); + + const hasActiveFilters = search || category || severity; + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + + return ( +
+ {/* Header */} + +
+
+

Admin Logs

+ +
+

+ View audit logs across all system activities +

+
+
+ + {/* Collapsible Filters */} + {showFilters && ( + +
+
+ Filters + {hasActiveFilters && ( + + )} +
+ + {/* Search */} + + + {/* Category & Severity Row */} +
+ + + +
+ + {/* Export Button */} + +
+
+ )} + + {/* Error State */} + {error && ( + +
+

Failed to load audit logs

+ +
+
+ )} + + {/* Loading State */} + {isLoading && ( + +
+
Loading logs...
+
+
+ )} + + {/* Empty State */} + {!isLoading && data?.logs.length === 0 && ( + +
+ + + +

No audit logs found

+
+
+ )} + + {/* Log Cards */} + {!isLoading && data?.logs.map((log: UnifiedAuditLog) => ( + +
+ {/* Header Row */} +
+
+ + {categoryLabels[log.category]} + + + {log.severity} + +
+ + {formatDate(log.createdAt)} + +
+ + {/* Action */} +

+ {log.action} +

+ + {/* Metadata */} +
+ {log.userId && ( + + User: {log.userId.substring(0, 16)}... + + )} + {log.resourceType && log.resourceId && ( + + {log.resourceType}: {log.resourceId.substring(0, 10)}... + + )} +
+
+
+ ))} + + {/* Pagination */} + {!isLoading && data && data.total > pageSize && ( + +
+ + + Page {page + 1} of {totalPages} + + +
+
+ )} + + {/* Total Count */} + {!isLoading && data && ( +
+ {data.total} total log{data.total !== 1 ? 's' : ''} +
+ )} +
+ ); +}; + +export default AdminLogsMobileScreen; diff --git a/frontend/src/features/admin/types/admin.types.ts b/frontend/src/features/admin/types/admin.types.ts index 9e14b60..645b915 100644 --- a/frontend/src/features/admin/types/admin.types.ts +++ b/frontend/src/features/admin/types/admin.types.ts @@ -375,3 +375,39 @@ export interface RestorePreviewResponse { export interface ExecuteRestoreRequest { createSafetyBackup?: boolean; } + +// ============================================ +// Unified Audit Log types (new centralized audit system) +// ============================================ + +export type AuditLogCategory = 'auth' | 'vehicle' | 'user' | 'system' | 'admin'; +export type AuditLogSeverity = 'info' | 'warning' | 'error'; + +export interface UnifiedAuditLog { + id: string; + category: AuditLogCategory; + severity: AuditLogSeverity; + userId: string | null; + action: string; + resourceType: string | null; + resourceId: string | null; + details: Record | null; + createdAt: string; +} + +export interface UnifiedAuditLogsResponse { + logs: UnifiedAuditLog[]; + total: number; + limit: number; + offset: number; +} + +export interface AuditLogFilters { + search?: string; + category?: AuditLogCategory; + severity?: AuditLogSeverity; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +} diff --git a/frontend/src/pages/admin/AdminCatalogPage.tsx b/frontend/src/pages/admin/AdminCatalogPage.tsx index 1f44956..ce79cdf 100644 --- a/frontend/src/pages/admin/AdminCatalogPage.tsx +++ b/frontend/src/pages/admin/AdminCatalogPage.tsx @@ -50,7 +50,6 @@ import { import { adminApi } from '../../features/admin/api/admin.api'; import { AdminSectionHeader, - AuditLogPanel, } from '../../features/admin/components'; import { CatalogSearchResult, @@ -489,9 +488,6 @@ export const AdminCatalogPage: React.FC = () => { )} - {/* Audit Log */} - - {/* Delete Confirmation Dialog */} !deleting && setDeleteDialogOpen(false)}> diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx new file mode 100644 index 0000000..440ef3d --- /dev/null +++ b/frontend/src/pages/admin/AdminLogsPage.tsx @@ -0,0 +1,401 @@ +/** + * @ai-summary Admin Logs page for viewing centralized audit logs + * @ai-context Desktop version with search, filters, and CSV export + */ + +import React, { useState, useCallback } from 'react'; +import { Navigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Container, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Paper, + InputAdornment, + Stack, +} from '@mui/material'; +import { + Search, + Download, + FilterList, + Clear, +} from '@mui/icons-material'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { useAdminAccess } from '../../core/auth/useAdminAccess'; +import { useUnifiedAuditLogs, useExportAuditLogs } from '../../features/admin/hooks/useAuditLogs'; +import { + AuditLogCategory, + AuditLogSeverity, + AuditLogFilters, + UnifiedAuditLog, +} from '../../features/admin/types/admin.types'; + +// Helper to format date +const formatDate = (dateString: string): string => { + return dayjs(dateString).format('MMM DD, YYYY HH:mm:ss'); +}; + +// Severity chip colors +const severityColors: Record = { + info: 'info', + warning: 'warning', + error: 'error', +}; + +// Category labels for display +const categoryLabels: Record = { + auth: 'Authentication', + vehicle: 'Vehicle', + user: 'User', + system: 'System', + admin: 'Admin', +}; + +export const AdminLogsPage: React.FC = () => { + const { loading: authLoading, isAdmin } = useAdminAccess(); + + // Filter state + const [search, setSearch] = useState(''); + const [category, setCategory] = useState(''); + const [severity, setSeverity] = useState(''); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + // Pagination state + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + // Build filters object + const filters: AuditLogFilters = { + ...(search && { search }), + ...(category && { category }), + ...(severity && { severity }), + ...(startDate && { startDate: startDate.toISOString() }), + ...(endDate && { endDate: endDate.toISOString() }), + limit: rowsPerPage, + offset: page * rowsPerPage, + }; + + // Query + const { data, isLoading, error } = useUnifiedAuditLogs(filters); + const exportMutation = useExportAuditLogs(); + + // Handlers + const handleSearch = useCallback((event: React.ChangeEvent) => { + setSearch(event.target.value); + setPage(0); // Reset to first page + }, []); + + const handleCategoryChange = useCallback((event: { target: { value: string } }) => { + setCategory(event.target.value as AuditLogCategory | ''); + setPage(0); + }, []); + + const handleSeverityChange = useCallback((event: { target: { value: string } }) => { + setSeverity(event.target.value as AuditLogSeverity | ''); + setPage(0); + }, []); + + const handleStartDateChange = useCallback((date: dayjs.Dayjs | null) => { + setStartDate(date); + setPage(0); + }, []); + + const handleEndDateChange = useCallback((date: dayjs.Dayjs | null) => { + setEndDate(date); + setPage(0); + }, []); + + const handleClearFilters = useCallback(() => { + setSearch(''); + setCategory(''); + setSeverity(''); + setStartDate(null); + setEndDate(null); + setPage(0); + }, []); + + const handleExport = useCallback(() => { + const exportFilters: AuditLogFilters = { + ...(search && { search }), + ...(category && { category }), + ...(severity && { severity }), + ...(startDate && { startDate: startDate.toISOString() }), + ...(endDate && { endDate: endDate.toISOString() }), + }; + exportMutation.mutate(exportFilters); + }, [search, category, severity, startDate, endDate, exportMutation]); + + const handleChangePage = useCallback((_: unknown, newPage: number) => { + setPage(newPage); + }, []); + + const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }, []); + + // Loading state + if (authLoading) { + return ( + + + + + + ); + } + + // Redirect non-admins + if (!isAdmin) { + return ; + } + + const hasActiveFilters = search || category || severity || startDate || endDate; + + return ( + + + + + Admin Logs + + + View and search centralized audit logs across all system activities + + + + {/* Filters */} + + + + + Filters + {hasActiveFilters && ( + + )} + + + + {/* Search */} + + + + ), + }} + /> + + {/* Category */} + + Category + + + + {/* Severity */} + + Severity + + + + {/* Start Date */} + + + {/* End Date */} + + + + {/* Export Button */} + + + + + + + {/* Error State */} + {error && ( + + + + Failed to load audit logs: {error.message} + + + + )} + + {/* Logs Table */} + + + + + + Timestamp + Category + Severity + User + Action + Resource + + + + {isLoading ? ( + + + + + Loading logs... + + + + ) : data?.logs.length === 0 ? ( + + + + No audit logs found + + + + ) : ( + data?.logs.map((log: UnifiedAuditLog) => ( + + + {formatDate(log.createdAt)} + + + + + + + + + {log.userId ? ( + + {log.userId.substring(0, 20)}... + + ) : ( + + System + + )} + + + + {log.action} + + + + {log.resourceType && log.resourceId ? ( + + {log.resourceType}: {log.resourceId.substring(0, 12)}... + + ) : ( + + - + + )} + + + )) + )} + +
+
+ + {/* Pagination */} + +
+
+
+ ); +}; -- 2.49.1 From 80275c1670cdb017eeac57a2b048b2bdb05278c6 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:18:45 -0600 Subject: [PATCH 2/6] fix: Remove duplicate audit-logs route from admin routes (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old /api/admin/audit-logs route in admin.routes.ts conflicted with the new centralized audit-log feature. Removed the old route since we're now using the unified audit logging system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/features/admin/api/admin.routes.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/features/admin/api/admin.routes.ts b/backend/src/features/admin/api/admin.routes.ts index d52d8c0..857927f 100644 --- a/backend/src/features/admin/api/admin.routes.ts +++ b/backend/src/features/admin/api/admin.routes.ts @@ -9,7 +9,6 @@ import { UsersController } from './users.controller'; import { CreateAdminInput, AdminAuth0SubInput, - AuditLogsQueryInput, BulkCreateAdminInput, BulkRevokeAdminInput, BulkReinstateAdminInput, @@ -78,11 +77,7 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => { handler: adminController.reinstateAdmin.bind(adminController) }); - // GET /api/admin/audit-logs - Fetch audit trail - fastify.get<{ Querystring: AuditLogsQueryInput }>('/admin/audit-logs', { - preHandler: [fastify.requireAdmin], - handler: adminController.getAuditLogs.bind(adminController) - }); + // NOTE: GET /api/admin/audit-logs moved to audit-log feature (centralized audit logging) // POST /api/admin/admins/bulk - Create multiple admins fastify.post<{ Body: BulkCreateAdminInput }>('/admin/admins/bulk', { -- 2.49.1 From 6f2ac3e22b9f0b51ba9fb414d4bac5d9afe41c67 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:32:12 -0600 Subject: [PATCH 3/6] fix: Add Audit Logs navigation to Admin Console settings (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routes and screen components for AdminLogsPage were implemented but the navigation links to access them were missing from both desktop and mobile Settings pages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../settings/mobile/MobileSettingsScreen.tsx | 8 +++++++ frontend/src/pages/SettingsPage.tsx | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index e3083c5..7a02751 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -509,6 +509,14 @@ export const MobileSettingsScreen: React.FC = () => {
Backup & Restore
Create backups and restore data
+ diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index a36f8b3..716925c 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -572,6 +572,30 @@ export const SettingsPage: React.FC = () => { + + + + + navigate('/garage/settings/admin/logs')} + sx={{ + backgroundColor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.dark' + } + }} + > + View + + + )} -- 2.49.1 From cdfba3c1a862d908b02fc4116b37c84bd00ffcac Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:42:42 -0600 Subject: [PATCH 4/6] fix: Add audit-log to migration order (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit_logs table migration was not being executed because the audit-log feature was missing from MIGRATION_ORDER in run-all.ts, causing 500 errors when accessing the audit logs API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/_system/migrations/run-all.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index c557e87..a134ee9 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -27,6 +27,7 @@ const MIGRATION_ORDER = [ 'features/notifications', // Depends on maintenance and documents 'features/user-profile', // User profile management; independent 'features/terms-agreement', // Terms & Conditions acceptance audit trail + 'features/audit-log', // Centralized audit logging; independent ]; // Base directory where migrations are copied inside the image (set by Dockerfile) -- 2.49.1 From fbde51b8fd95745b0cb1856af5d1803a76089d33 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:08:41 -0600 Subject: [PATCH 5/6] feat: Add login/logout audit logging (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add login event logging to getUserStatus() controller method - Create POST /auth/track-logout endpoint for logout tracking Frontend: - Create useLogout hook that wraps Auth0 logout with audit tracking - Update all logout locations to use the new hook (SettingsPage, Layout, MobileSettingsScreen, useDeletion) Login events are logged when the frontend calls /auth/user-status after Auth0 callback. Logout events are logged via fire-and-forget call to /auth/track-logout before Auth0 logout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/features/auth/api/auth.controller.ts | 53 +++++++++++++++++++ backend/src/features/auth/api/auth.routes.ts | 6 +++ frontend/src/components/Layout.tsx | 6 ++- frontend/src/core/auth/useLogout.ts | 46 ++++++++++++++++ .../features/settings/hooks/useDeletion.ts | 7 +-- .../settings/mobile/MobileSettingsScreen.tsx | 10 ++-- frontend/src/pages/SettingsPage.tsx | 6 ++- 7 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 frontend/src/core/auth/useLogout.ts diff --git a/backend/src/features/auth/api/auth.controller.ts b/backend/src/features/auth/api/auth.controller.ts index 63823a5..2de1a33 100644 --- a/backend/src/features/auth/api/auth.controller.ts +++ b/backend/src/features/auth/api/auth.controller.ts @@ -193,6 +193,9 @@ export class AuthController { * GET /api/auth/user-status * Get user status for routing decisions * Protected endpoint - requires JWT + * + * Note: This endpoint is called once per Auth0 callback (from CallbackPage/CallbackMobileScreen). + * We log the login event here since it's the first authenticated request after Auth0 redirect. */ async getUserStatus(request: FastifyRequest, reply: FastifyReply) { try { @@ -200,6 +203,17 @@ export class AuthController { const result = await this.authService.getUserStatus(userId); + // Log login event to audit trail (called once per Auth0 callback) + const ipAddress = this.getClientIp(request); + await auditLogService.info( + 'auth', + userId, + 'User login', + 'user', + userId, + { ipAddress } + ).catch(err => logger.error('Failed to log login audit event', { error: err })); + logger.info('User status retrieved', { userId: userId.substring(0, 8) + '...', emailVerified: result.emailVerified, @@ -287,4 +301,43 @@ export class AuthController { }); } } + + /** + * POST /api/auth/track-logout + * Track user logout event for audit logging + * Protected endpoint - requires JWT + * + * Called by frontend before Auth0 logout to capture the logout event. + * Returns success even if audit logging fails (non-blocking). + */ + async trackLogout(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user.sub; + const ipAddress = this.getClientIp(request); + + // Log logout event to audit trail + await auditLogService.info( + 'auth', + userId, + 'User logout', + 'user', + userId, + { ipAddress } + ).catch(err => logger.error('Failed to log logout audit event', { error: err })); + + logger.info('User logout tracked', { + userId: userId.substring(0, 8) + '...', + }); + + return reply.code(200).send({ success: true }); + } catch (error: any) { + // Don't block logout on audit failure - always return success + logger.error('Failed to track logout', { + error, + userId: (request as any).user?.sub, + }); + + return reply.code(200).send({ success: true }); + } + } } diff --git a/backend/src/features/auth/api/auth.routes.ts b/backend/src/features/auth/api/auth.routes.ts index 4941d32..a3b460b 100644 --- a/backend/src/features/auth/api/auth.routes.ts +++ b/backend/src/features/auth/api/auth.routes.ts @@ -48,4 +48,10 @@ export const authRoutes: FastifyPluginAsync = async ( preHandler: [fastify.authenticate], handler: authController.requestPasswordReset.bind(authController), }); + + // POST /api/auth/track-logout - Track logout event for audit (requires JWT) + fastify.post('/auth/track-logout', { + preHandler: [fastify.authenticate], + handler: authController.trackLogout.bind(authController), + }); }; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 58297d5..161db61 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { Link, useLocation } from 'react-router-dom'; +import { useLogout } from '../core/auth/useLogout'; import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; @@ -26,7 +27,8 @@ interface LayoutProps { } export const Layout: React.FC = ({ children, mobileMode = false }) => { - const { user, logout } = useAuth0(); + const { user } = useAuth0(); + const { logout } = useLogout(); const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); const location = useLocation(); @@ -222,7 +224,7 @@ export const Layout: React.FC = ({ children, mobileMode = false }) variant="secondary" size="sm" className="w-full" - onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })} + onClick={() => logout()} > Sign Out diff --git a/frontend/src/core/auth/useLogout.ts b/frontend/src/core/auth/useLogout.ts new file mode 100644 index 0000000..d80cfec --- /dev/null +++ b/frontend/src/core/auth/useLogout.ts @@ -0,0 +1,46 @@ +/** + * @ai-summary Custom logout hook with audit logging + * @ai-context Tracks logout event before Auth0 logout for audit trail + */ + +import { useAuth0 } from '@auth0/auth0-react'; +import { useCallback } from 'react'; + +/** + * Custom hook that wraps Auth0 logout with audit tracking. + * Calls /api/auth/track-logout before performing Auth0 logout. + * The audit call is fire-and-forget to ensure logout always completes. + */ +export const useLogout = () => { + const { logout: auth0Logout, getAccessTokenSilently } = useAuth0(); + + const logout = useCallback(async () => { + // Fire-and-forget audit call (don't block logout) + try { + const token = await getAccessTokenSilently({ cacheMode: 'on' as const }); + if (token) { + // Use fetch directly to avoid axios interceptor issues during logout + fetch('/api/auth/track-logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }).catch(() => { + // Silently ignore errors - don't block logout + }); + } + } catch { + // Token not available - proceed with logout anyway + } + + // Perform Auth0 logout + auth0Logout({ + logoutParams: { + returnTo: window.location.origin, + }, + }); + }, [auth0Logout, getAccessTokenSilently]); + + return { logout }; +}; diff --git a/frontend/src/features/settings/hooks/useDeletion.ts b/frontend/src/features/settings/hooks/useDeletion.ts index ffd3253..f34237d 100644 --- a/frontend/src/features/settings/hooks/useDeletion.ts +++ b/frontend/src/features/settings/hooks/useDeletion.ts @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuth0 } from '@auth0/auth0-react'; +import { useLogout } from '../../../core/auth/useLogout'; import { profileApi } from '../api/profile.api'; import { RequestDeletionRequest } from '../types/profile.types'; import toast from 'react-hot-toast'; @@ -36,7 +37,7 @@ export const useDeletionStatus = () => { export const useRequestDeletion = () => { const queryClient = useQueryClient(); - const { logout } = useAuth0(); + const { logout } = useLogout(); return useMutation({ mutationFn: (data: RequestDeletionRequest) => profileApi.requestDeletion(data), @@ -45,9 +46,9 @@ export const useRequestDeletion = () => { queryClient.invalidateQueries({ queryKey: ['user-profile'] }); toast.success(response.data.message || 'Account deletion scheduled'); - // Logout after 2 seconds + // Logout after 2 seconds (with audit tracking) setTimeout(() => { - logout({ logoutParams: { returnTo: window.location.origin } }); + logout(); }, 2000); }, onError: (error: ApiError) => { diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index 7a02751..78e3f1a 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; +import { useLogout } from '../../../core/auth/useLogout'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { useSettings } from '../hooks/useSettings'; @@ -75,7 +76,8 @@ const Modal: React.FC = ({ isOpen, onClose, title, children }) => { }; export const MobileSettingsScreen: React.FC = () => { - const { user, logout } = useAuth0(); + const { user } = useAuth0(); + const { logout } = useLogout(); const { navigateToScreen } = useNavigationStore(); const { settings, updateSetting, isLoading, error } = useSettings(); const { data: profile, isLoading: profileLoading } = useProfile(); @@ -98,11 +100,7 @@ export const MobileSettingsScreen: React.FC = () => { }, [profile, isEditingProfile]); const handleLogout = () => { - logout({ - logoutParams: { - returnTo: window.location.origin - } - }); + logout(); }; const handleExportData = () => { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 716925c..6f57d09 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import React, { useState } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { useNavigate } from 'react-router-dom'; +import { useLogout } from '../core/auth/useLogout'; import { useUnits } from '../core/units/UnitsContext'; import { useAdminAccess } from '../core/auth/useAdminAccess'; import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile'; @@ -44,7 +45,8 @@ import CancelIcon from '@mui/icons-material/Cancel'; import { Card } from '../shared-minimal/components/Card'; export const SettingsPage: React.FC = () => { - const { user, logout } = useAuth0(); + const { user } = useAuth0(); + const { logout } = useLogout(); const navigate = useNavigate(); const { unitSystem, setUnitSystem } = useUnits(); const { isAdmin, loading: adminLoading } = useAdminAccess(); @@ -73,7 +75,7 @@ export const SettingsPage: React.FC = () => { }, [profile, isEditingProfile]); const handleLogout = () => { - logout({ logoutParams: { returnTo: window.location.origin } }); + logout(); }; const handleEditProfile = () => { -- 2.49.1 From 911b7c0e3a873583bff0f69e2527e3a24a101088 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:30:57 -0600 Subject: [PATCH 6/6] fix: Display user email instead of Auth0 UID in audit logs (refs #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add userEmail field to AuditLogEntry type in backend and frontend - Update audit-log repository to LEFT JOIN with user_profiles table - Update AdminLogsPage to show email with fallback to truncated userId - Update AdminLogsMobileScreen with same display logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../audit-log/data/audit-log.repository.ts | 40 +++++++++++-------- .../audit-log/domain/audit-log.types.ts | 1 + .../admin/mobile/AdminLogsMobileScreen.tsx | 8 +++- .../src/features/admin/types/admin.types.ts | 1 + frontend/src/pages/admin/AdminLogsPage.tsx | 8 +++- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/backend/src/features/audit-log/data/audit-log.repository.ts b/backend/src/features/audit-log/data/audit-log.repository.ts index 94774f6..622e5b7 100644 --- a/backend/src/features/audit-log/data/audit-log.repository.ts +++ b/backend/src/features/audit-log/data/audit-log.repository.ts @@ -39,37 +39,37 @@ export class AuditLogRepository { let paramIndex = 1; if (filters.search) { - conditions.push(`action ILIKE $${paramIndex}`); + conditions.push(`al.action ILIKE $${paramIndex}`); params.push(`%${this.escapeLikePattern(filters.search)}%`); paramIndex++; } if (filters.category) { - conditions.push(`category = $${paramIndex}`); + conditions.push(`al.category = $${paramIndex}`); params.push(filters.category); paramIndex++; } if (filters.severity) { - conditions.push(`severity = $${paramIndex}`); + conditions.push(`al.severity = $${paramIndex}`); params.push(filters.severity); paramIndex++; } if (filters.userId) { - conditions.push(`user_id = $${paramIndex}`); + conditions.push(`al.user_id = $${paramIndex}`); params.push(filters.userId); paramIndex++; } if (filters.startDate) { - conditions.push(`created_at >= $${paramIndex}`); + conditions.push(`al.created_at >= $${paramIndex}`); params.push(filters.startDate); paramIndex++; } if (filters.endDate) { - conditions.push(`created_at <= $${paramIndex}`); + conditions.push(`al.created_at <= $${paramIndex}`); params.push(filters.endDate); paramIndex++; } @@ -86,7 +86,8 @@ export class AuditLogRepository { const query = ` INSERT INTO audit_logs (category, severity, user_id, action, resource_type, resource_id, details) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, category, severity, user_id, action, resource_type, resource_id, details, created_at + RETURNING id, category, severity, user_id, action, resource_type, resource_id, details, created_at, + NULL::text as user_email `; try { @@ -117,14 +118,17 @@ export class AuditLogRepository { const { whereClause, params, nextParamIndex } = this.buildWhereClause(filters); // Count query - const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`; + const countQuery = `SELECT COUNT(*) as total FROM audit_logs al ${whereClause}`; - // Data query with pagination + // Data query with pagination - LEFT JOIN to get user email const dataQuery = ` - SELECT id, category, severity, user_id, action, resource_type, resource_id, details, created_at - FROM audit_logs + SELECT al.id, al.category, al.severity, al.user_id, al.action, + al.resource_type, al.resource_id, al.details, al.created_at, + up.email as user_email + FROM audit_logs al + LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub ${whereClause} - ORDER BY created_at DESC + ORDER BY al.created_at DESC LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1} `; @@ -156,16 +160,19 @@ export class AuditLogRepository { const { whereClause, params } = this.buildWhereClause(filters); // First, count total matching records - const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`; + const countQuery = `SELECT COUNT(*) as total FROM audit_logs al ${whereClause}`; const countResult = await this.pool.query(countQuery, params); const totalCount = parseInt(countResult.rows[0].total, 10); const truncated = totalCount > MAX_EXPORT_RECORDS; const query = ` - SELECT id, category, severity, user_id, action, resource_type, resource_id, details, created_at - FROM audit_logs + SELECT al.id, al.category, al.severity, al.user_id, al.action, + al.resource_type, al.resource_id, al.details, al.created_at, + up.email as user_email + FROM audit_logs al + LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub ${whereClause} - ORDER BY created_at DESC + ORDER BY al.created_at DESC LIMIT ${MAX_EXPORT_RECORDS} `; @@ -222,6 +229,7 @@ export class AuditLogRepository { category: row.category as AuditLogEntry['category'], severity: row.severity as AuditLogEntry['severity'], userId: row.user_id as string | null, + userEmail: (row.user_email as string | null) || null, action: row.action as string, resourceType: row.resource_type as string | null, resourceId: row.resource_id as string | null, diff --git a/backend/src/features/audit-log/domain/audit-log.types.ts b/backend/src/features/audit-log/domain/audit-log.types.ts index 0c17de6..d1c98fb 100644 --- a/backend/src/features/audit-log/domain/audit-log.types.ts +++ b/backend/src/features/audit-log/domain/audit-log.types.ts @@ -21,6 +21,7 @@ export interface AuditLogEntry { category: AuditLogCategory; severity: AuditLogSeverity; userId: string | null; + userEmail: string | null; action: string; resourceType: string | null; resourceId: string | null; diff --git a/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx index 02e8cec..3089f61 100644 --- a/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminLogsMobileScreen.tsx @@ -268,11 +268,15 @@ const AdminLogsMobileScreen: React.FC = () => { {/* Metadata */}
- {log.userId && ( + {log.userEmail ? ( + + User: {log.userEmail} + + ) : log.userId ? ( User: {log.userId.substring(0, 16)}... - )} + ) : null} {log.resourceType && log.resourceId && ( {log.resourceType}: {log.resourceId.substring(0, 10)}... diff --git a/frontend/src/features/admin/types/admin.types.ts b/frontend/src/features/admin/types/admin.types.ts index 645b915..6ae46bf 100644 --- a/frontend/src/features/admin/types/admin.types.ts +++ b/frontend/src/features/admin/types/admin.types.ts @@ -388,6 +388,7 @@ export interface UnifiedAuditLog { category: AuditLogCategory; severity: AuditLogSeverity; userId: string | null; + userEmail: string | null; action: string; resourceType: string | null; resourceId: string | null; diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx index 440ef3d..c37b7a8 100644 --- a/frontend/src/pages/admin/AdminLogsPage.tsx +++ b/frontend/src/pages/admin/AdminLogsPage.tsx @@ -351,8 +351,12 @@ export const AdminLogsPage: React.FC = () => { /> - {log.userId ? ( - + {log.userEmail ? ( + + {log.userEmail} + + ) : log.userId ? ( + {log.userId.substring(0, 20)}... ) : ( -- 2.49.1