From 936471542a4c6897f406f3cf39f6c348093c03ca Mon Sep 17 00:00:00 2001 From: Esteban Date: Tue, 27 Jan 2026 21:00:39 -0600 Subject: [PATCH] Audit changes --- water-api/src/controllers/audit.controller.ts | 25 --- water-api/src/controllers/auth.controller.ts | 45 +++++ water-api/src/index.ts | 4 + water-api/src/middleware/audit.middleware.ts | 191 ++++++++++++++++++ water-api/src/routes/audit.routes.ts | 28 --- water-api/src/services/audit.service.ts | 33 +-- 6 files changed, 241 insertions(+), 85 deletions(-) create mode 100644 water-api/src/middleware/audit.middleware.ts diff --git a/water-api/src/controllers/audit.controller.ts b/water-api/src/controllers/audit.controller.ts index 76c1021..49471a8 100644 --- a/water-api/src/controllers/audit.controller.ts +++ b/water-api/src/controllers/audit.controller.ts @@ -1,16 +1,7 @@ -/** - * Audit Controller - * Handles HTTP requests for audit log operations - */ - import { Response } from 'express'; import { AuthenticatedRequest } from '../types'; import * as auditService from '../services/audit.service'; -/** - * GET /audit-logs - * Get audit logs with filters and pagination (admin only) - */ export async function getAuditLogs( req: AuthenticatedRequest, res: Response @@ -68,10 +59,6 @@ export async function getAuditLogs( } } -/** - * GET /audit-logs/:id - * Get a single audit log by ID (admin only) - */ export async function getAuditLogById( req: AuthenticatedRequest, res: Response @@ -103,10 +90,6 @@ export async function getAuditLogById( } } -/** - * GET /audit-logs/record/:tableName/:recordId - * Get audit logs for a specific record (admin only) - */ export async function getAuditLogsForRecord( req: AuthenticatedRequest, res: Response @@ -130,10 +113,6 @@ export async function getAuditLogsForRecord( } } -/** - * GET /audit-logs/statistics - * Get audit statistics (admin only) - */ export async function getAuditStatistics( req: AuthenticatedRequest, res: Response @@ -158,10 +137,6 @@ export async function getAuditStatistics( } } -/** - * GET /audit-logs/my-activity - * Get current user's own audit logs - */ export async function getMyActivity( req: AuthenticatedRequest, res: Response diff --git a/water-api/src/controllers/auth.controller.ts b/water-api/src/controllers/auth.controller.ts index c85ef80..04d5b0f 100644 --- a/water-api/src/controllers/auth.controller.ts +++ b/water-api/src/controllers/auth.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { AuthenticatedRequest } from '../middleware/auth.middleware'; import * as authService from '../services/auth.service'; import { LoginInput, RefreshInput } from '../validators/auth.validator'; +import { createAuditLog, getIpAddress, getUserAgent } from '../services/audit.service'; /** * POST /auth/login @@ -14,6 +15,19 @@ export async function login(req: Request, res: Response): Promise { const result = await authService.login(email, password); + createAuditLog({ + userId: result.user.id, + userEmail: result.user.email, + userName: result.user.name, + action: 'LOGIN', + tableName: 'users', + recordId: result.user.id, + description: `User logged in successfully`, + ipAddress: getIpAddress(req), + userAgent: getUserAgent(req), + success: true, + }).catch(err => console.error('Failed to log login:', err)); + res.status(200).json({ success: true, data: { @@ -24,6 +38,22 @@ export async function login(req: Request, res: Response): Promise { }); } catch (error) { const message = error instanceof Error ? error.message : 'Login failed'; + const { email } = req.body as LoginInput; + + if (email) { + createAuditLog({ + userId: email, + userEmail: email, + userName: email, + action: 'LOGIN', + tableName: 'users', + description: `Failed login attempt`, + ipAddress: getIpAddress(req), + userAgent: getUserAgent(req), + success: false, + errorMessage: message, + }).catch(err => console.error('Failed to log failed login:', err)); + } // Use 401 for authentication failures if (message === 'Invalid email or password') { @@ -89,6 +119,21 @@ export async function logout(req: AuthenticatedRequest, res: Response): Promise< await authService.logout(userId, refreshToken); } + if (req.user) { + createAuditLog({ + userId: req.user.id, + userEmail: req.user.email, + userName: req.user.role || req.user.email, + action: 'LOGOUT', + tableName: 'users', + recordId: req.user.id, + description: `User logged out`, + ipAddress: getIpAddress(req), + userAgent: getUserAgent(req), + success: true, + }).catch(err => console.error('Failed to log logout:', err)); + } + res.status(200).json({ success: true, message: 'Logout successful', diff --git a/water-api/src/index.ts b/water-api/src/index.ts index 1171ed1..ac11f02 100644 --- a/water-api/src/index.ts +++ b/water-api/src/index.ts @@ -7,6 +7,7 @@ import helmet from 'helmet'; import routes from './routes'; import logger from './utils/logger'; import { testConnection } from './config/database'; +import { auditMiddleware } from './middleware/audit.middleware'; const app: Application = express(); @@ -42,6 +43,9 @@ app.use(cors({ app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); +// Audit logging middleware (before routes) +app.use(auditMiddleware); + // Health check endpoint app.get('/health', (_req: Request, res: Response) => { res.status(200).json({ diff --git a/water-api/src/middleware/audit.middleware.ts b/water-api/src/middleware/audit.middleware.ts new file mode 100644 index 0000000..d0064aa --- /dev/null +++ b/water-api/src/middleware/audit.middleware.ts @@ -0,0 +1,191 @@ +import { Request, Response, NextFunction } from 'express'; +import { createAuditLog, getIpAddress, getUserAgent, type AuditAction } from '../services/audit.service'; +import { AuthenticatedRequest } from '../types'; + +function methodToAction(method: string): AuditAction { + switch (method.toUpperCase()) { + case 'POST': + return 'CREATE'; + case 'PUT': + case 'PATCH': + return 'UPDATE'; + case 'DELETE': + return 'DELETE'; + case 'GET': + return 'READ'; + default: + return 'READ'; + } +} + +function extractTableName(path: string): string { + const segments = path.replace(/^\/api\//, '').split('/'); + let tableName = segments[0] || 'unknown'; + + const tableMapping: Record = { + 'auth': 'users', + 'webhooks': 'webhooks', + 'bulk-upload': 'bulk_operations', + 'audit-logs': 'audit_logs', + }; + + return tableMapping[tableName] || tableName; +} + +function extractRecordId(req: Request): string | undefined { + if (req.params.id) { + return req.params.id; + } + + const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + const match = req.path.match(uuidRegex); + + return match ? match[0] : undefined; +} + +const EXCLUDED_PATHS = [ + '/api/auth/refresh', + '/api/audit-logs', + '/webhooks', + '/health', +]; + +function shouldExclude(path: string): boolean { + return EXCLUDED_PATHS.some(excluded => path.startsWith(excluded)); +} + +export function auditMiddleware( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + console.log(`[AUDIT-MIDDLEWARE] Request received: ${req.method} ${req.path}`); + + if (shouldExclude(req.path)) { + console.log(`[AUDIT-MIDDLEWARE] Path excluded: ${req.path}`); + next(); + return; + } + + if (!req.path.startsWith('/api')) { + console.log(`[AUDIT-MIDDLEWARE] Not an API path: ${req.path}`); + next(); + return; + } + + console.log(`[AUDIT-MIDDLEWARE] Setting up audit tracking for: ${req.method} ${req.path}`); + + const originalSend = res.send; + const originalJson = res.json; + let responseBody: any; + + res.send = function (body: any): Response { + responseBody = body; + return originalSend.call(this, body); + }; + + res.json = function (body: any): Response { + responseBody = body; + return originalJson.call(this, body); + }; + + res.on('finish', async () => { + try { + console.log('🔍 [Audit] Response finished:', { + path: req.path, + method: req.method, + user: req.user ? req.user.email : 'NO USER', + statusCode: res.statusCode + }); + + if (!req.user) { + console.log('⚠️ [Audit] Skipping - no authenticated user'); + return; + } + + const action = methodToAction(req.method); + const tableName = extractTableName(req.path); + const recordId = extractRecordId(req); + const success = res.statusCode >= 200 && res.statusCode < 400; + + let description = `${req.method} ${req.path}`; + + if (req.path.includes('/login')) { + description = 'User logged in'; + } else if (req.path.includes('/logout')) { + description = 'User logged out'; + } else if (req.path.includes('/password')) { + description = 'Password changed'; + } + + const auditData = { + userId: req.user.userId, + userEmail: req.user.email, + userName: req.user.roleName || req.user.email, + action, + tableName, + recordId, + description, + ipAddress: getIpAddress(req), + userAgent: getUserAgent(req), + success, + newValues: ['POST', 'PUT', 'PATCH'].includes(req.method) + ? sanitizeData(req.body) + : undefined, + errorMessage: !success && responseBody?.error + ? responseBody.error + : undefined, + }; + + console.log('✅ [Audit] Creating audit log...', { + action, + tableName, + recordId, + userEmail: req.user.email + }); + + createAuditLog(auditData) + .then(logId => { + console.log('✅ [Audit] Log created successfully:', logId); + }) + .catch(err => { + console.error('❌ [Audit] Failed to create audit log:', err); + console.error(' Audit data was:', JSON.stringify(auditData, null, 2)); + }); + + } catch (error) { + console.error('❌ [Audit] Error in audit middleware:', error); + } + }); + + next(); +} + +function sanitizeData(data: any): any { + if (!data || typeof data !== 'object') { + return data; + } + + const sanitized = { ...data }; + + const sensitiveFields = [ + 'password', + 'password_hash', + 'currentPassword', + 'newPassword', + 'token', + 'accessToken', + 'refreshToken', + 'secret', + 'api_key', + 'apiKey', + ]; + + sensitiveFields.forEach(field => { + if (field in sanitized) { + sanitized[field] = '[REDACTED]'; + } + }); + + return sanitized; +} diff --git a/water-api/src/routes/audit.routes.ts b/water-api/src/routes/audit.routes.ts index 5e87ca5..17dc434 100644 --- a/water-api/src/routes/audit.routes.ts +++ b/water-api/src/routes/audit.routes.ts @@ -1,19 +1,9 @@ -/** - * Audit Routes - * Defines API endpoints for audit log operations - */ - import { Router } from 'express'; import * as auditController from '../controllers/audit.controller'; import { authenticateToken, requireRole } from '../middleware/auth.middleware'; const router = Router(); -/** - * GET /audit-logs - * Get all audit logs with filters (admin only) - * Query params: userId, action, tableName, recordId, startDate, endDate, success, page, limit - */ router.get( '/', authenticateToken, @@ -21,22 +11,12 @@ router.get( auditController.getAuditLogs ); -/** - * GET /audit-logs/my-activity - * Get current user's own audit logs - * Query params: page, limit - */ router.get( '/my-activity', authenticateToken, auditController.getMyActivity ); -/** - * GET /audit-logs/statistics - * Get audit statistics (admin only) - * Query params: days - */ router.get( '/statistics', authenticateToken, @@ -44,10 +24,6 @@ router.get( auditController.getAuditStatistics ); -/** - * GET /audit-logs/:id - * Get a single audit log by ID (admin only) - */ router.get( '/:id', authenticateToken, @@ -55,10 +31,6 @@ router.get( auditController.getAuditLogById ); -/** - * GET /audit-logs/record/:tableName/:recordId - * Get audit logs for a specific record (admin only) - */ router.get( '/record/:tableName/:recordId', authenticateToken, diff --git a/water-api/src/services/audit.service.ts b/water-api/src/services/audit.service.ts index f81b2fc..4dc10c5 100644 --- a/water-api/src/services/audit.service.ts +++ b/water-api/src/services/audit.service.ts @@ -1,8 +1,3 @@ -/** - * Audit Service - * Handles logging of user actions and system changes - */ - import { query } from '../config/database'; import type { Request } from 'express'; @@ -64,9 +59,6 @@ export interface AuditLogFilters { limit?: number; } -/** - * Create an audit log entry - */ export async function createAuditLog(data: AuditLogData): Promise { const sql = ` SELECT log_audit( @@ -105,9 +97,6 @@ export async function createAuditLog(data: AuditLogData): Promise { return result.rows[0].log_id; } -/** - * Get audit logs with filters and pagination - */ export async function getAuditLogs( filters: AuditLogFilters = {} ): Promise<{ logs: AuditLog[]; total: number }> { @@ -165,7 +154,6 @@ export async function getAuditLogs( const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - // Get total count const countSql = ` SELECT COUNT(*) as total FROM audit_logs @@ -175,7 +163,6 @@ export async function getAuditLogs( const countResult = await query(countSql, params); const total = parseInt(countResult.rows[0].total, 10); - // Get paginated results const dataSql = ` SELECT * FROM audit_logs @@ -192,9 +179,6 @@ export async function getAuditLogs( }; } -/** - * Get audit log by ID - */ export async function getAuditLogById(id: string): Promise { const sql = ` SELECT * @@ -206,9 +190,6 @@ export async function getAuditLogById(id: string): Promise { return (result.rows[0] as AuditLog) || null; } -/** - * Get audit logs for a specific record - */ export async function getAuditLogsForRecord( tableName: string, recordId: string @@ -224,9 +205,6 @@ export async function getAuditLogsForRecord( return result.rows as AuditLog[]; } -/** - * Get audit statistics - */ export async function getAuditStatistics(days: number = 30): Promise { const sql = ` SELECT * @@ -239,9 +217,6 @@ export async function getAuditStatistics(days: number = 30): Promise { return result.rows; } -/** - * Helper: Extract IP address from request - */ export function getIpAddress(req: Request): string | undefined { return ( (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || @@ -251,18 +226,12 @@ export function getIpAddress(req: Request): string | undefined { ); } -/** - * Helper: Extract user agent from request - */ export function getUserAgent(req: Request): string | undefined { return req.headers['user-agent']; } -/** - * Helper: Create audit log from authenticated request - */ export async function logAction( - req: any, // AuthenticatedRequest + req: any, action: AuditAction, tableName: string, options: {