first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
'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();
};
+105
View File
@@ -0,0 +1,105 @@
// backend/middleware/auth.js
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'sap-portal-secret';
function verifyToken(req, res, next) {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
if (!token) return res.status(401).json({ success: false, message: 'No token provided' });
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ success: false, message: 'Invalid or expired token' });
}
}
// Only sap_adder can approve → push to SAP B1
function verifyAdmin(req, res, next) {
if (req.user?.role === 'admin' || req.user?.role === 'sap_adder')
return next();
return res.status(403).json({ success: false, message: 'SAP Adder or Admin role required' });
}
// User-administration gate: full Admin, SAP Adder (historically also manages
// users) and the System Admin role (user management ONLY — deliberately NOT part
// of verifyAdmin, so sub-admins can't touch system settings / approvals).
function verifyUserAdmin(req, res, next) {
if (['admin', 'sap_adder', 'system_admin'].includes(req.user?.role))
return next();
return res.status(403).json({ success: false, message: 'Admin, SAP Adder or System Admin role required' });
}
// Every step assignment grants a set of perms. Legacy entries are plain
// strings ("work_order:prepared_qa") from before per-step perms existed —
// normalize them to the full perm set so nobody's access silently shrinks.
// 'approve' is distinct from 'edit': performing the sign-off action at a
// stage (e.g. "did QC check it, yes/no") is NOT the same capability as
// editing the record's own fields (product/batch/materials/etc.) — someone
// can be trusted to check a stage off without being able to alter the data.
const ALL_PERMS = ['view', 'add', 'edit', 'approve', 'delete'];
function normalizeSteps(raw) {
const arr = Array.isArray(raw) ? raw : [];
return arr.map(s => {
if (typeof s === 'string') return { step: s, perms: ALL_PERMS.slice() };
if (s && typeof s === 'object' && s.step) return { step: s.step, perms: Array.isArray(s.perms) ? s.perms : ALL_PERMS.slice() };
return null;
}).filter(Boolean);
}
// Does this user hold `perm` on the exact step `fullKey` ("workflow:key")?
function hasStepPerm(user, fullKey, perm) {
if (user?.role === 'admin') return true;
const steps = normalizeSteps(user?.approvalSteps);
const entry = steps.find(s => s.step === fullKey);
return !!entry && entry.perms.includes(perm);
}
// Does this user hold `perm` on ANY step within `workflow`? Used for
// workflow-wide actions like viewing a list, where the specific stage
// doesn't matter — holding the perm on one step is enough.
function hasWorkflowPerm(user, workflow, perm) {
if (user?.role === 'admin') return true;
const steps = normalizeSteps(user?.approvalSteps);
return steps.some(s => s.step.startsWith(`${workflow}:`) && s.perms.includes(perm));
}
// Generic Approval Steps gate — 'workflow:key' (see services/approvalStepsStore.js).
// Admin always passes. Everyone else needs `perm` (default 'view') on this
// exact step in req.user.approvalSteps (baked into the JWT at login).
function requireApprovalStep(fullKey, perm = 'view') {
return (req, res, next) => {
if (hasStepPerm(req.user, fullKey, perm)) return next();
res.status(403).json({ success: false, message: `You are not assigned "${perm}" on approval step: ${fullKey}` });
};
}
// Same as above but workflow-wide (any step in the workflow grants access) —
// for actions like listing/viewing that aren't tied to one specific stage.
function requireWorkflowPerm(workflow, perm = 'view') {
return (req, res, next) => {
if (hasWorkflowPerm(req.user, workflow, perm)) return next();
res.status(403).json({ success: false, message: `You are not assigned "${perm}" on workflow: ${workflow}` });
};
}
// "Is this step assigned to the user at all?" — true if the user holds the
// step with ANY permission (view/add/edit/approve/delete). Used for actions
// (Issuance, Receipt, Close…) where being assigned the step means the user
// may perform it, regardless of which specific perm boxes were ticked.
function hasStepAssigned(user, fullKey) {
if (user && user.role === 'admin') return true;
return ALL_PERMS.some(p => hasStepPerm(user, fullKey, p));
}
function requireStepAssigned(fullKey) {
return (req, res, next) => {
if (hasStepAssigned(req.user, fullKey)) return next();
res.status(403).json({ success: false, message: `You are not assigned to approval step: ${fullKey}` });
};
}
module.exports = {
verifyToken, verifyAdmin, verifyUserAdmin, requireApprovalStep, requireWorkflowPerm,
requireStepAssigned, hasStepAssigned,
normalizeSteps, hasStepPerm, hasWorkflowPerm, ALL_PERMS,
};