'use strict'; // middleware/auditLogger.js — records every mutating API request into the audit // trail (services/auditStore.js). Registered globally, before the route mounts. // For update/delete on known entities it fetches the record's state BEFORE the // handler runs and again AFTER, and stores a field-level before→after diff so // the viewer shows "quantity: 10 → 11", not raw JSON. const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET || 'sap-portal-secret'; const audit = require('../services/auditStore'); const SENSITIVE = /pass|pwd|token|secret|signature|logo|image|base64|otp|credential/i; // entity path-segment → loader that returns the current record (or null). const LOADERS = { 'batch-intimations': id => require('../services/batchIntimationStore').findById(id), 'work-orders': id => require('../services/workOrderStore').findById(id), 'production-orders': id => require('../services/productionOrderStore').findById(id), 'requirements': id => require('../services/requirementStore').findById(id), 'users': id => require('../services/hanaUsers').findById(id), 'oee': id => require('../services/oeeStore').getById(id), }; // Fields that are noise in a diff. const SKIP_DIFF = new Set(['updatedAt', 'createdAt', 'id', 'steps', 'currentStep', 'passwordHash', 'hasSapLogin', 'hasSignature']); function sanitize(v, depth = 0) { if (v == null) return v; if (typeof v === 'string') return v.length > 500 ? v.slice(0, 500) + '…[truncated]' : v; if (typeof v === 'number' || typeof v === 'boolean') return v; if (depth >= 5) return '[…]'; if (Array.isArray(v)) return v.slice(0, 100).map(x => sanitize(x, depth + 1)); if (typeof v === 'object') { const o = {}; for (const k of Object.keys(v)) { o[k] = SENSITIVE.test(k) ? '[redacted]' : sanitize(v[k], depth + 1); } return o; } return String(v); } function isPrim(v) { return v == null || ['string', 'number', 'boolean'].includes(typeof v); } function base(path) { const m = String(path).split(/[.\[]/).pop(); return m ? m.replace(/\]$/, '') : path; } function redact(field, val) { if (SENSITIVE.test(base(field))) return '[redacted]'; if (typeof val === 'string' && val.length > 200) return val.slice(0, 200) + '…'; return val; } // A friendly label for an array item — its itemCode/code/name/id if present. function itemLabel(obj, i) { if (obj && typeof obj === 'object') { for (const k of ['itemCode', 'code', 'docNo', 'name', 'label', 'id']) { if (obj[k] != null && obj[k] !== '') return String(obj[k]); } } return String(i); } // Recursive before→after diff → flat list of { field:'a.b[label].c', from, to }. function deepDiff(a, b, path, out) { if (out.length >= 120) return; if (isPrim(a) && isPrim(b)) { if (String(a == null ? '' : a) !== String(b == null ? '' : b)) out.push({ field: path, from: redact(path, a), to: redact(path, b) }); return; } if (Array.isArray(a) || Array.isArray(b)) { const aa = Array.isArray(a) ? a : [], bb = Array.isArray(b) ? b : []; const n = Math.max(aa.length, bb.length); for (let i = 0; i < n && out.length < 120; i++) deepDiff(aa[i], bb[i], `${path}[${itemLabel(aa[i] || bb[i], i)}]`, out); return; } const ao = a || {}, bo = b || {}; for (const k of new Set([...Object.keys(ao), ...Object.keys(bo)])) { if (SKIP_DIFF.has(k)) continue; if (out.length >= 120) break; deepDiff(ao[k], bo[k], path ? `${path}.${k}` : k, out); } } function primitives(obj) { const o = {}; for (const k of Object.keys(obj || {})) { if (SKIP_DIFF.has(k)) continue; if (isPrim(obj[k])) o[k] = redact(k, obj[k]); } return o; } function derive(pathname) { const seg = pathname.replace(/^\/api\//, '').split('/').filter(Boolean); const entity = seg[0] || ''; const rest = seg.slice(1); const entityId = rest.find(s => /^\d+$/.test(s)) || null; const sub = rest.filter(s => !/^\d+$/.test(s)).join('/') || ''; return { entity, entityId, sub }; } function actionFor(method, entity, sub) { if (entity === 'auth') { if (/login/i.test(sub)) return 'login'; if (/logout/i.test(sub)) return 'logout'; if (/impersonate/i.test(sub)) return 'impersonate'; } if (/cancel/i.test(sub)) return 'cancel'; if (/reject/i.test(sub)) return 'reject'; if (/approve|action|workflow|advance/i.test(sub)) return 'approve'; return ({ POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete' })[method] || method.toLowerCase(); } function getToken(req) { const a = req.headers.authorization || ''; if (a.startsWith('Bearer ')) return a.slice(7); if (a) return a; const c = req.headers.cookie || ''; const m = c.match(/(?:^|;\s*)portal_token=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; } module.exports = async function auditLogger(req, res, next) { const method = req.method; if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return next(); if (!req.path.startsWith('/api/')) return next(); let bodySnap = null; try { if (req.body && Object.keys(req.body).length) bodySnap = sanitize(req.body); } catch (_e) {} // Capture the response payload so a FAILED request's actual error message // (every route here replies with {success:false, message:'...'} on // failure) makes it into the audit record — previously only the HTTP // status code was kept, so a Failed row gave no clue what actually broke. let resBody = null; const origJson = res.json.bind(res); res.json = (body) => { resBody = body; return origJson(body); }; const { entity, entityId, sub } = derive(req.path); // Work Order row stamps (Issued/Received/Verified — routes/workOrders.js's // POST /:id/row/:section/:index/mark) are a POST, not the PUT/PATCH this // before/after diffing was built for, but the SAME "what actually changed" // question applies — especially for a re-sign (see woVerifyOverride), // where a before→after diff is exactly what shows "Manish → Sarvesh" or // an old date replaced with a backdated one. Parsed straight off the path // since derive() strips numeric segments like the row index out of `sub`. const markMatch = entity === 'work-orders' ? req.path.match(/\/work-orders\/(\d+)\/row\/(raw|pack)\/(\d+)\/mark$/) : null; // Snapshot the record's current state BEFORE the handler mutates it. let before = null; if (entityId && (method === 'PUT' || method === 'PATCH' || method === 'DELETE') && LOADERS[entity]) { try { before = await LOADERS[entity](entityId); } catch (_e) {} } else if (markMatch && LOADERS[entity]) { try { before = await LOADERS[entity](entityId); } catch (_e) {} } res.on('finish', () => { (async () => { try { let u = {}; try { const tok = getToken(req); if (tok) u = jwt.verify(tok, SECRET) || {}; } catch (_e) {} // req.auditActor: set by routes whose callers aren't ERP users (Sales // customer portal "customer:", Sales external API). let username = u.username || u.name || req.auditActor || (req.body && (req.body.username || (String(req.originalUrl || '').startsWith('/api/sales-portal/login') && req.body.cardCode ? `customer:${req.body.cardCode}` : ''))) || ''; let role = u.role || ''; if (!username && u.id) { try { const full = await require('../services/hanaUsers').findById(u.id); if (full) { username = full.username; role = role || full.role; } } catch (_e) {} } if (!username) username = '(anonymous)'; let action = actionFor(method, entity, sub); const ok = res.statusCode < 400; // Build the details payload — prefer a before→after diff for edits. let details = bodySnap; let markSummary = ''; if (ok && method === 'DELETE') { details = before ? { deleted: primitives(before) } : bodySnap; } else if (ok && before && (method === 'PUT' || method === 'PATCH') && LOADERS[entity]) { let after = null; try { after = await LOADERS[entity](entityId); } catch (_e) {} const changes = []; deepDiff(before, after, '', changes); if (changes.length) details = { changes }; } else if (ok && markMatch && LOADERS[entity]) { // Work Order row stamp — figure out whether this was a fresh sign or // a RE-sign (see woVerifyOverride) of a row that already had this // exact stamp, and say so plainly, with old→new signer/date, rather // than making someone open the raw diff to notice a signature was // just overwritten. const which = ((bodySnap && bodySnap.which) || '').toLowerCase(); const section = markMatch[2], idx = parseInt(markMatch[3], 10); const beforeRow = before && ((section === 'raw' ? before.rawMaterials : before.packingMaterials) || [])[idx]; let after = null; try { after = await LOADERS[entity](entityId); } catch (_e) {} const afterRow = after && ((section === 'raw' ? after.rawMaterials : after.packingMaterials) || [])[idx]; const wasStamped = beforeRow && beforeRow[`${which}At`]; action = wasStamped ? 're-sign' : 'sign'; const itemLbl = (afterRow || beforeRow || {}).itemCode || `row ${idx}`; const oldSig = beforeRow ? `${beforeRow[`${which}ByName`] || beforeRow[`${which}By`] || ''}${beforeRow[`${which}At`] ? ' (' + beforeRow[`${which}At`] + ')' : ''}` : ''; const newSig = afterRow ? `${afterRow[`${which}ByName`] || afterRow[`${which}By`] || ''}${afterRow[`${which}At`] ? ' (' + afterRow[`${which}At`] + ')' : ''}` : ''; markSummary = wasStamped ? `re-signed "${which}" on ${entity} #${entityId} ${itemLbl}: ${oldSig} → ${newSig}` : `signed "${which}" on ${entity} #${entityId} ${itemLbl}: ${newSig}`; details = { which, itemCode: itemLbl, from: oldSig || null, to: newSig || null, ...(bodySnap || {}) }; } // On failure, the error message returned to the client is the whole // point of looking at this row — surface it in BOTH the summary // (visible in the list without opening the row) and details.error. const errorMsg = !ok && resBody && typeof resBody === 'object' ? (resBody.message || resBody.error || '') : ''; if (errorMsg) details = { ...(details && typeof details === 'object' ? details : {}), error: errorMsg }; // Impersonation is baked into the token itself (see server.js's // POST /api/auth/impersonate) — surface it here so an audit row made // while impersonating is never mistaken for the target user's own // action; the real actor's identity must stay visible. const impBy = u.impersonatedBy ? `${u.impersonatedBy.name || u.impersonatedBy.username} (@${u.impersonatedBy.username})` : ''; if (impBy) details = { ...(details && typeof details === 'object' ? details : {}), impersonatedBy: u.impersonatedBy }; const ip = (req.headers['x-forwarded-for'] || (req.socket && req.socket.remoteAddress) || '').toString().split(',')[0].trim(); audit.record({ userId: u.id || null, username, role, method, action, entity, entityId, sub, path: (req.originalUrl || req.url || '').split('?')[0], status: res.statusCode, ok, summary: `${impBy ? '[impersonated by ' + impBy + '] ' : ''}${markSummary || `${action} ${entity}${entityId ? ' #' + entityId : ''}${sub ? ' (' + sub + ')' : ''}`}${errorMsg ? ' — ' + errorMsg : ''}`, details, ip, company: (req.query && req.query.company) || (req.body && req.body.company) || '', }); } catch (_e) { /* never let auditing break a request */ } })(); }); next(); };