From 13cc4528ff0e143231361aa7a99317a6b4859c3e Mon Sep 17 00:00:00 2001 From: Esteban Date: Wed, 28 Jan 2026 13:28:05 -0600 Subject: [PATCH] Audit table with better data --- water-api/src/controllers/auth.controller.ts | 12 +- water-api/src/middleware/audit.middleware.ts | 313 ++++++++++++++++--- water-api/src/middleware/auth.middleware.ts | 23 +- water-api/src/services/auth.service.ts | 21 +- water-api/src/utils/jwt.ts | 20 +- 5 files changed, 300 insertions(+), 89 deletions(-) diff --git a/water-api/src/controllers/auth.controller.ts b/water-api/src/controllers/auth.controller.ts index 04d5b0f..a537fe0 100644 --- a/water-api/src/controllers/auth.controller.ts +++ b/water-api/src/controllers/auth.controller.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { AuthenticatedRequest } from '../middleware/auth.middleware'; +import type { AuthenticatedRequest } from '../types'; import * as authService from '../services/auth.service'; import { LoginInput, RefreshInput } from '../validators/auth.validator'; import { createAuditLog, getIpAddress, getUserAgent } from '../services/audit.service'; @@ -106,7 +106,7 @@ export async function refresh(req: Request, res: Response): Promise { */ export async function logout(req: AuthenticatedRequest, res: Response): Promise { try { - const userId = req.user?.id; + const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, error: 'Authentication required' }); @@ -121,12 +121,12 @@ export async function logout(req: AuthenticatedRequest, res: Response): Promise< if (req.user) { createAuditLog({ - userId: req.user.id, + userId: req.user.userId, userEmail: req.user.email, - userName: req.user.role || req.user.email, + userName: req.user.roleName || req.user.email, action: 'LOGOUT', tableName: 'users', - recordId: req.user.id, + recordId: req.user.userId, description: `User logged out`, ipAddress: getIpAddress(req), userAgent: getUserAgent(req), @@ -150,7 +150,7 @@ export async function logout(req: AuthenticatedRequest, res: Response): Promise< */ export async function getMe(req: AuthenticatedRequest, res: Response): Promise { try { - const userId = req.user?.id; + const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, error: 'Authentication required' }); diff --git a/water-api/src/middleware/audit.middleware.ts b/water-api/src/middleware/audit.middleware.ts index d0064aa..7db3c79 100644 --- a/water-api/src/middleware/audit.middleware.ts +++ b/water-api/src/middleware/audit.middleware.ts @@ -18,18 +18,141 @@ function methodToAction(method: string): AuditAction { } } -function extractTableName(path: string): string { - const segments = path.replace(/^\/api\//, '').split('/'); - let tableName = segments[0] || 'unknown'; +function extractSection(path: string): string { + const cleanPath = path.replace(/^\/api\//, '').replace(/^\//, ''); + const segments = cleanPath.split('/').filter(s => s.length > 0); - const tableMapping: Record = { + if (segments.length === 0) return 'general'; + + const sectionMapping: Record = { + 'auth': 'authentication', + 'me': 'profile', + 'users': 'user-management', + 'roles': 'role-management', + 'meters': 'meters', + 'concentrators': 'concentrators', + 'gateways': 'gateways', + 'devices': 'devices', + 'projects': 'projects', + 'readings': 'readings', + 'bulk-upload': 'bulk-operations', + 'audit-logs': 'audit', + 'webhooks': 'webhooks', + }; + + return sectionMapping[segments[0]] || segments[0]; +} + +function extractTableName(path: string): string { + const cleanPath = path.replace(/^\/api\//, '').replace(/^\//, ''); + const segments = cleanPath.split('/').filter(s => s.length > 0); + + if (segments.length === 0) { + return 'unknown'; + } + + const baseTableMapping: Record = { 'auth': 'users', + 'me': 'users', + 'users': 'users', + 'roles': 'roles', + 'meters': 'meters', + 'concentrators': 'concentrators', + 'gateways': 'gateways', + 'devices': 'devices', + 'projects': 'projects', + 'readings': 'readings', 'webhooks': 'webhooks', 'bulk-upload': 'bulk_operations', 'audit-logs': 'audit_logs', }; - - return tableMapping[tableName] || tableName; + + const operations = new Set([ + 'password', + 'stats', + 'health', + 'template', + 'summary', + 'statistics', + 'my-activity', + 'record', + 'refresh', + 'logout', + 'uplink', + 'join', + 'ack', + 'dev-eui', + ]); + + const nestedResourceMapping: Record = { + 'meters/readings': 'readings', + 'concentrators/meters': 'meters', + 'gateways/devices': 'devices', + 'projects/meters': 'meters', + 'projects/concentrators': 'concentrators', + }; + + const firstSegment = segments[0]; + const baseTable = baseTableMapping[firstSegment] || firstSegment; + + if (segments.length === 1) { + return baseTable; + } + + const secondSegment = segments[1]; + const thirdSegment = segments[2]; + + if (segments.length >= 3 && isUUID(secondSegment)) { + const nestedKey = `${firstSegment}/${thirdSegment}`; + + if (nestedResourceMapping[nestedKey]) { + return nestedResourceMapping[nestedKey]; + } + + if (operations.has(thirdSegment)) { + return baseTable; + } + + if (baseTableMapping[thirdSegment] || isPluralResourceName(thirdSegment)) { + return baseTableMapping[thirdSegment] || thirdSegment; + } + + return baseTable; + } + + if (firstSegment === 'bulk-upload' && secondSegment) { + return baseTableMapping[secondSegment] || secondSegment; + } + + if (firstSegment === 'audit-logs') { + if (secondSegment === 'record' && segments.length >= 4) { + return segments[3]; + } + return 'audit_logs'; + } + + if (firstSegment === 'auth') { + return 'users'; + } + + if (firstSegment === 'devices' && secondSegment === 'dev-eui') { + return 'devices'; + } + + if (segments.length === 2 && isUUID(secondSegment)) { + return baseTable; + } + + return baseTable; +} + +function isUUID(str: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(str) || /^\d+$/.test(str); +} + +function isPluralResourceName(str: string): boolean { + return str.endsWith('s') || str.endsWith('ies') || str.includes('-'); } function extractRecordId(req: Request): string | undefined { @@ -54,74 +177,157 @@ function shouldExclude(path: string): boolean { return EXCLUDED_PATHS.some(excluded => path.startsWith(excluded)); } +function generateDescription( + fullPath: string, + action: AuditAction, + tableName: string, + recordId?: string, + section?: string +): string { + const actionDescriptions: Record = { + 'CREATE': 'Created', + 'UPDATE': 'Updated', + 'DELETE': 'Deleted', + 'READ': 'Viewed', + 'LOGIN': 'Logged in', + 'LOGOUT': 'Logged out', + 'EXPORT': 'Exported', + 'BULK_UPLOAD': 'Bulk uploaded', + 'STATUS_CHANGE': 'Changed status of', + 'PERMISSION_CHANGE': 'Changed permissions for', + }; + + const tableLabels: Record = { + 'users': 'user', + 'roles': 'role', + 'meters': 'meter', + 'concentrators': 'concentrator', + 'gateways': 'gateway', + 'devices': 'device', + 'projects': 'project', + 'readings': 'reading', + 'bulk_operations': 'bulk operation', + }; + + const actionLabel = actionDescriptions[action] || action; + const tableLabel = tableLabels[tableName] || tableName; + + if (fullPath === '/api/me' || fullPath === '/me' || fullPath.endsWith('/auth/me')) { + return 'Viewed own profile'; + } + + if (fullPath.includes('/users') && action === 'READ' && !recordId) { + return 'Viewed users list'; + } + + if (fullPath.includes('/meters') && action === 'READ' && !recordId) { + return 'Viewed meters list'; + } + + if (fullPath.includes('/concentrators') && action === 'READ' && !recordId) { + return 'Viewed concentrators list'; + } + + if (fullPath.includes('/projects') && action === 'READ' && !recordId) { + return 'Viewed projects list'; + } + + if (fullPath.includes('/readings') && action === 'READ' && !recordId) { + return 'Viewed readings list'; + } + + if (recordId) { + if (tableName === 'unknown' && section) { + return `${actionLabel} record in ${section} (ID: ${recordId.substring(0, 8)}...)`; + } + return `${actionLabel} ${tableLabel} (ID: ${recordId.substring(0, 8)}...)`; + } + + if (tableName === 'unknown' && section) { + return `${actionLabel} in ${section} section`; + } + + return `${actionLabel} ${tableLabel}`; +} + 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; + let responseBody: unknown; - res.send = function (body: any): Response { + res.send = function (body: unknown): Response { responseBody = body; return originalSend.call(this, body); }; - res.json = function (body: any): Response { + res.json = function (body: unknown): 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'); + if (!req.user || !req.user.userId || !req.user.email) { return; } const action = methodToAction(req.method); - const tableName = extractTableName(req.path); + const fullPath = (req.originalUrl || req.url).split('?')[0]; + const section = extractSection(fullPath); + const tableName = extractTableName(fullPath); const recordId = extractRecordId(req); const success = res.statusCode >= 200 && res.statusCode < 400; - let description = `${req.method} ${req.path}`; + let description = generateDescription(fullPath, action, tableName, recordId, section); - if (req.path.includes('/login')) { - description = 'User logged in'; - } else if (req.path.includes('/logout')) { + if (fullPath.includes('/login')) { + description = 'User logged in successfully'; + } else if (fullPath.includes('/logout')) { description = 'User logged out'; - } else if (req.path.includes('/password')) { + } else if (fullPath.includes('/password')) { description = 'Password changed'; + } else if (fullPath.includes('/bulk-upload')) { + description = `Bulk upload for ${tableName}`; } + let newValues: unknown = undefined; + let oldValues: unknown = undefined; + + if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body && Object.keys(req.body).length > 0) { + newValues = sanitizeData(req.body); + } + + const bodyData = (responseBody as Record)?.data; + if (req.method === 'GET' && recordId && bodyData && !Array.isArray(bodyData)) { + newValues = sanitizeData(bodyData); + } + + if (req.method === 'DELETE' && bodyData) { + oldValues = sanitizeData(bodyData); + } + + const userWithName = req.user as { name?: string; userName?: string; roleName: string; email: string }; + const userName = userWithName.name || userWithName.userName || userWithName.roleName || userWithName.email; + const auditData = { userId: req.user.userId, userEmail: req.user.email, - userName: req.user.roleName || req.user.email, + userName, action, tableName, recordId, @@ -129,21 +335,13 @@ export function auditMiddleware( ipAddress: getIpAddress(req), userAgent: getUserAgent(req), success, - newValues: ['POST', 'PUT', 'PATCH'].includes(req.method) - ? sanitizeData(req.body) - : undefined, - errorMessage: !success && responseBody?.error - ? responseBody.error + newValues: newValues as Record | undefined, + oldValues: oldValues as Record | undefined, + errorMessage: !success && typeof (responseBody as Record)?.error === 'string' + ? (responseBody as Record).error as string : 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); @@ -161,16 +359,25 @@ export function auditMiddleware( next(); } -function sanitizeData(data: any): any { - if (!data || typeof data !== 'object') { +function sanitizeData(data: unknown): unknown { + if (!data) { return data; } - const sanitized = { ...data }; + if (typeof data !== 'object') { + return data; + } + + if (Array.isArray(data)) { + return data.map(item => sanitizeData(item)); + } + + const sanitized: Record = {}; const sensitiveFields = [ 'password', 'password_hash', + 'passwordHash', 'currentPassword', 'newPassword', 'token', @@ -179,13 +386,21 @@ function sanitizeData(data: any): any { 'secret', 'api_key', 'apiKey', + 'credit_card', + 'creditCard', + 'ssn', + 'cvv', ]; - sensitiveFields.forEach(field => { - if (field in sanitized) { - sanitized[field] = '[REDACTED]'; + for (const [key, value] of Object.entries(data)) { + if (sensitiveFields.includes(key)) { + sanitized[key] = '[REDACTED]'; + } else if (value && typeof value === 'object') { + sanitized[key] = sanitizeData(value); + } else { + sanitized[key] = value; } - }); + } return sanitized; } diff --git a/water-api/src/middleware/auth.middleware.ts b/water-api/src/middleware/auth.middleware.ts index 8d0cf0a..1e07d5f 100644 --- a/water-api/src/middleware/auth.middleware.ts +++ b/water-api/src/middleware/auth.middleware.ts @@ -1,16 +1,6 @@ -import { Request, Response, NextFunction } from 'express'; +import { Response, NextFunction } from 'express'; import { verifyAccessToken } from '../utils/jwt'; - -/** - * Extended Request interface with authenticated user - */ -export interface AuthenticatedRequest extends Request { - user?: { - id: string; - email: string; - role: string; - }; -} +import { AuthenticatedRequest } from '../types'; /** * Middleware to authenticate JWT access tokens @@ -47,9 +37,10 @@ export function authenticateToken( } req.user = { - id: decoded.id, - email: decoded.email, - role: decoded.role, + userId: (decoded as any).userId || (decoded as any).id, + email: (decoded as any).email, + roleId: (decoded as any).roleId || (decoded as any).role, + roleName: (decoded as any).roleName || (decoded as any).role, }; next(); @@ -74,7 +65,7 @@ export function requireRole(...roles: string[]) { return; } - if (!roles.includes(req.user.role)) { + if (!roles.includes(req.user.roleId) && !roles.includes(req.user.roleName)) { res.status(403).json({ error: 'Insufficient permissions' }); return; } diff --git a/water-api/src/services/auth.service.ts b/water-api/src/services/auth.service.ts index b4ae3f7..5d22a11 100644 --- a/water-api/src/services/auth.service.ts +++ b/water-api/src/services/auth.service.ts @@ -78,15 +78,15 @@ export async function login( throw new Error('Invalid email or password'); } - // Generate tokens const accessToken = generateAccessToken({ - id: user.id, + userId: user.id, email: user.email, - role: user.role_name, + roleId: user.id, + roleName: user.role_name, }); const refreshToken = generateRefreshToken({ - id: user.id, + userId: user.id, }); // Hash and store refresh token @@ -136,7 +136,8 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri // Hash token to check against database const hashedToken = hashToken(refreshToken); - // Find token in database + const userId = (decoded as any).userId || (decoded as any).id; + const tokenResult = await query<{ id: string; expires_at: Date; @@ -144,7 +145,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri `SELECT id, expires_at FROM refresh_tokens WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1`, - [hashedToken, decoded.id] + [hashedToken, userId] ); const storedToken = tokenResult.rows[0]; @@ -175,7 +176,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri JOIN roles r ON u.role_id = r.id WHERE u.id = $1 AND u.is_active = true LIMIT 1`, - [decoded.id] + [userId] ); const user = userResult.rows[0]; @@ -184,11 +185,11 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri throw new Error('User not found'); } - // Generate new access token const accessToken = generateAccessToken({ - id: user.id, + userId: user.id, email: user.email, - role: user.role_name, + roleId: user.id, + roleName: user.role_name, }); return { accessToken }; diff --git a/water-api/src/utils/jwt.ts b/water-api/src/utils/jwt.ts index 323bb18..bc98cd9 100644 --- a/water-api/src/utils/jwt.ts +++ b/water-api/src/utils/jwt.ts @@ -1,10 +1,14 @@ -import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken'; +import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken'; import config from '../config'; import logger from './logger'; +import type { JwtPayload } from '../types'; interface TokenPayload { - id: string; + userId?: string; email?: string; + roleId?: string; + roleName?: string; + id?: string; role?: string; [key: string]: unknown; } @@ -58,7 +62,7 @@ export const generateRefreshToken = (payload: TokenPayload): string => { * @param token - JWT access token to verify * @returns Decoded payload if valid, null if invalid or expired */ -export const verifyAccessToken = (token: string): JwtPayload | null => { +export const verifyAccessToken = (token: string): any => { const options: VerifyOptions = { algorithms: ['HS256'], }; @@ -68,7 +72,7 @@ export const verifyAccessToken = (token: string): JwtPayload | null => { token, config.jwt.accessTokenSecret, options - ) as JwtPayload; + ); return decoded; } catch (error) { if (error instanceof jwt.TokenExpiredError) { @@ -91,7 +95,7 @@ export const verifyAccessToken = (token: string): JwtPayload | null => { * @param token - JWT refresh token to verify * @returns Decoded payload if valid, null if invalid or expired */ -export const verifyRefreshToken = (token: string): JwtPayload | null => { +export const verifyRefreshToken = (token: string): any => { const options: VerifyOptions = { algorithms: ['HS256'], }; @@ -101,7 +105,7 @@ export const verifyRefreshToken = (token: string): JwtPayload | null => { token, config.jwt.refreshTokenSecret, options - ) as JwtPayload; + ); return decoded; } catch (error) { if (error instanceof jwt.TokenExpiredError) { @@ -124,9 +128,9 @@ export const verifyRefreshToken = (token: string): JwtPayload | null => { * @param token - JWT token to decode * @returns Decoded payload or null */ -export const decodeToken = (token: string): JwtPayload | null => { +export const decodeToken = (token: string): any => { try { - const decoded = jwt.decode(token) as JwtPayload | null; + const decoded = jwt.decode(token); return decoded; } catch (error) { logger.error('Error decoding token', {