Files
sap-erp/middleware/auditLogger.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

208 lines
11 KiB
JavaScript

'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:<code>", 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();
};