// src/middleware/dbAccessLogger.js // === SECURITY HARDENING: DATABASE ACCESS LOGGING === // Logs all database access for security auditing and compliance /** * Database Access Logging * * Logs all database queries for security auditing: * - Query type (SELECT, INSERT, UPDATE, DELETE) * - Table names accessed * - Timestamp * - User context (if available from request) * - IP address * - Query parameters (sanitized - no sensitive data) * * Configuration: * - DB_ACCESS_LOGGING_ENABLED: Set to 'true' to enable logging (default: false) * - DB_ACCESS_LOG_LEVEL: 'all' (all queries) or 'sensitive' (only sensitive tables) (default: 'sensitive') * * Sensitive tables (always logged if enabled): * - users * - otp_codes * - otp_requests * - refresh_tokens * - auth_audit */ const db = require('../db'); const LOGGING_ENABLED = process.env.DB_ACCESS_LOGGING_ENABLED === 'true' || process.env.DB_ACCESS_LOGGING_ENABLED === '1'; const LOG_LEVEL = process.env.DB_ACCESS_LOG_LEVEL || 'sensitive'; // 'all' or 'sensitive' // Tables that contain sensitive data (always logged) const SENSITIVE_TABLES = [ 'users', 'otp_codes', 'otp_requests', 'refresh_tokens', 'auth_audit', 'user_devices', ]; /** * Extract table names from SQL query * @param {string} query - SQL query text * @returns {string[]} - Array of table names */ function extractTableNames(query) { const tables = []; const upperQuery = query.toUpperCase(); // Match FROM, JOIN, UPDATE, INSERT INTO patterns const patterns = [ /FROM\s+([a-z_]+)/gi, /JOIN\s+([a-z_]+)/gi, /UPDATE\s+([a-z_]+)/gi, /INSERT\s+INTO\s+([a-z_]+)/gi, /DELETE\s+FROM\s+([a-z_]+)/gi, ]; patterns.forEach(pattern => { const matches = query.matchAll(pattern); for (const match of matches) { if (match[1]) { tables.push(match[1].toLowerCase()); } } }); return [...new Set(tables)]; // Remove duplicates } /** * Determine query type from SQL * @param {string} query - SQL query text * @returns {string} - Query type (SELECT, INSERT, UPDATE, DELETE, etc.) */ function getQueryType(query) { const upperQuery = query.trim().toUpperCase(); if (upperQuery.startsWith('SELECT')) return 'SELECT'; if (upperQuery.startsWith('INSERT')) return 'INSERT'; if (upperQuery.startsWith('UPDATE')) return 'UPDATE'; if (upperQuery.startsWith('DELETE')) return 'DELETE'; if (upperQuery.startsWith('CREATE')) return 'CREATE'; if (upperQuery.startsWith('ALTER')) return 'ALTER'; if (upperQuery.startsWith('DROP')) return 'DROP'; return 'OTHER'; } /** * Sanitize query parameters (remove sensitive data) * @param {Array} params - Query parameters * @returns {Array} - Sanitized parameters (sensitive values replaced with '[REDACTED]') */ function sanitizeParams(params) { if (!params || !Array.isArray(params)) { return []; } return params.map(param => { if (typeof param === 'string') { // Check if it looks like a phone number if (/^\+?\d{10,15}$/.test(param)) { return '[REDACTED_PHONE]'; } // Check if it looks like a token or hash if (param.length > 50) { return '[REDACTED_TOKEN]'; } // Check if it looks like an encrypted value (format: iv:authTag:data) if (param.includes(':') && param.split(':').length === 3) { return '[REDACTED_ENCRYPTED]'; } } // For other types, return as-is (numbers, booleans, etc.) return param; }); } /** * Check if query should be logged * @param {string} query - SQL query text * @returns {boolean} */ function shouldLogQuery(query) { if (!LOGGING_ENABLED) { return false; } if (LOG_LEVEL === 'all') { return true; } // Log sensitive tables const tables = extractTableNames(query); return tables.some(table => SENSITIVE_TABLES.includes(table)); } /** * Log database access * @param {Object} logData - Log data */ async function logDbAccess(logData) { if (!LOGGING_ENABLED) { return; } try { // Ensure db_access_log table exists await ensureDbAccessLogTable(); const { query, queryType, tables, params, userId = null, ipAddress = null, userAgent = null, duration = null, } = logData; await db.query( `INSERT INTO db_access_log ( query_type, tables_accessed, query_text, params_sanitized, user_id, ip_address, user_agent, duration_ms, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`, [ queryType, JSON.stringify(tables), query.substring(0, 1000), // Truncate long queries JSON.stringify(sanitizeParams(params)), userId, ipAddress, userAgent, duration, ] ); } catch (err) { // Don't throw - logging failures should not break the application console.error('[dbAccessLogger] Failed to log database access:', err.message); } } /** * Ensure db_access_log table exists */ async function ensureDbAccessLogTable() { try { await db.query(` CREATE TABLE IF NOT EXISTS db_access_log ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), query_type VARCHAR(20) NOT NULL, tables_accessed JSONB, query_text TEXT NOT NULL, params_sanitized JSONB, user_id UUID, ip_address VARCHAR(45), user_agent TEXT, duration_ms INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_db_access_log_created_at ON db_access_log(created_at); CREATE INDEX IF NOT EXISTS idx_db_access_log_user_id ON db_access_log(user_id); CREATE INDEX IF NOT EXISTS idx_db_access_log_tables ON db_access_log USING GIN(tables_accessed); CREATE INDEX IF NOT EXISTS idx_db_access_log_query_type ON db_access_log(query_type); `); } catch (err) { // Table might already exist or database might not be ready // This is fine, the insert will handle it } } /** * Wrap database query with logging * @param {string} query - SQL query * @param {Array} params - Query parameters * @param {Object} context - Request context (optional) * @returns {Promise} - Query result */ async function loggedQuery(query, params = [], context = {}) { const startTime = Date.now(); const queryType = getQueryType(query); const tables = extractTableNames(query); // Check if we should log this query if (!shouldLogQuery(query)) { // Execute query without logging return db.query(query, params); } try { // Execute query const result = await db.query(query, params); const duration = Date.now() - startTime; // Log access (fire and forget) logDbAccess({ query, queryType, tables, params, userId: context.userId || null, ipAddress: context.ipAddress || null, userAgent: context.userAgent || null, duration, }).catch(err => { console.error('[dbAccessLogger] Failed to log (non-blocking):', err.message); }); return result; } catch (err) { // Log failed query too const duration = Date.now() - startTime; logDbAccess({ query, queryType, tables, params, userId: context.userId || null, ipAddress: context.ipAddress || null, userAgent: context.userAgent || null, duration, error: err.message, }).catch(() => { // Ignore logging errors }); throw err; } } module.exports = { loggedQuery, logDbAccess, shouldLogQuery, isLoggingEnabled: () => LOGGING_ENABLED, };