206 lines
11 KiB
JavaScript
206 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) {}
|
|
let username = u.username || u.name || (req.body && req.body.username) || '';
|
|
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();
|
|
};
|