839 lines
47 KiB
JavaScript
839 lines
47 KiB
JavaScript
// routes/ivantiFpWorkflow.js
|
|
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const pool = require('../db');
|
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
|
const { ivantiFormPost, ivantiPost } = require('../helpers/ivantiApi');
|
|
const logAudit = require('../helpers/auditLog');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure helpers (exported for testing)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ALLOWED_EXTENSIONS = new Set([
|
|
'.pdf', '.png', '.jpg', '.jpeg', '.gif',
|
|
'.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'
|
|
]);
|
|
|
|
function isAllowedFileExtension(filename) {
|
|
if (!filename || typeof filename !== 'string') return false;
|
|
const ext = path.extname(filename).toLowerCase();
|
|
return ALLOWED_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
function validateFpWorkflowForm(body) {
|
|
const errors = {};
|
|
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
errors.name = 'Workflow name is required.';
|
|
} else if (body.name.trim().length > 255) {
|
|
errors.name = 'Workflow name must be 255 characters or fewer.';
|
|
}
|
|
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
|
errors.reason = 'Reason is required.';
|
|
}
|
|
if (body.description !== undefined && body.description !== null && body.description !== '') {
|
|
if (typeof body.description !== 'string') errors.description = 'Description must be a string.';
|
|
else if (body.description.length > 2000) errors.description = 'Description must be 2000 characters or fewer.';
|
|
}
|
|
if (!body.expirationDate || typeof body.expirationDate !== 'string' || body.expirationDate.trim().length === 0) {
|
|
errors.expirationDate = 'Expiration date is required.';
|
|
} else {
|
|
const parsed = new Date(body.expirationDate);
|
|
if (isNaN(parsed.getTime())) {
|
|
errors.expirationDate = 'Expiration date must be a valid date.';
|
|
} else {
|
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
const expDay = new Date(parsed); expDay.setHours(0, 0, 0, 0);
|
|
if (expDay <= today) errors.expirationDate = 'Expiration date must be in the future.';
|
|
else { const maxDate = new Date(today); maxDate.setDate(maxDate.getDate() + 120); if (expDay > maxDate) errors.expirationDate = 'Expiration date cannot be more than 120 days from today.'; }
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function buildSubjectFilterRequest(findingIds) {
|
|
return JSON.stringify({ subject: 'hostFinding', filterRequest: { filters: [{ field: 'id', exclusive: false, operator: 'IN', value: findingIds.map(id => String(id)).join(',') }] } });
|
|
}
|
|
|
|
function buildIvantiFormFields(formData, findingIds) {
|
|
const scopeMap = { 'Authorized': 'AUTHORIZED', 'None': 'NONE', 'Automated': 'AUTOMATED' };
|
|
return [
|
|
{ name: 'name', value: formData.name },
|
|
{ name: 'reason', value: formData.reason },
|
|
{ name: 'description', value: formData.description || '' },
|
|
{ name: 'expirationDate', value: formData.expirationDate },
|
|
{ name: 'overrideControl', value: scopeMap[formData.scopeOverride] || 'AUTHORIZED' },
|
|
{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) },
|
|
{ name: 'isEmptyWorkflow', value: findingIds.length === 0 ? 'true' : 'false' }
|
|
];
|
|
}
|
|
|
|
const LIFECYCLE_STATUSES = new Set(['submitted', 'approved', 'rejected', 'rework', 'resubmitted']);
|
|
|
|
function validateLifecycleTransition(currentStatus, newStatus) {
|
|
if (currentStatus === 'approved') return { valid: false, error: 'This submission is finalized and cannot be edited.' };
|
|
if (!LIFECYCLE_STATUSES.has(newStatus)) return { valid: false, error: 'Invalid lifecycle status.' };
|
|
return { valid: true };
|
|
}
|
|
|
|
function mergeFindings(existingJson, newIds) {
|
|
const existing = JSON.parse(existingJson || '[]');
|
|
const merged = [...new Set([...existing, ...newIds])];
|
|
return JSON.stringify(merged);
|
|
}
|
|
|
|
function buildSubmissionHistoryEntry(changeType, details, userId, username) {
|
|
return { user_id: userId, username: username, change_type: changeType, change_details_json: JSON.stringify(details), created_at: new Date().toISOString() };
|
|
}
|
|
|
|
function filterVisibleSubmissions(submissions) {
|
|
return submissions.filter(s => s.lifecycle_status !== 'approved' && s.dismissed_at == null);
|
|
}
|
|
|
|
function shouldShowDismissButton(submission) {
|
|
return submission.lifecycle_status === 'rejected' && submission.dismissed_at == null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resolve workflow batch UUID
|
|
// ---------------------------------------------------------------------------
|
|
async function resolveWorkflowBatchUuid(submission, apiKey, clientId, skipTls) {
|
|
if (submission.ivanti_workflow_batch_uuid) return submission.ivanti_workflow_batch_uuid;
|
|
|
|
const searchUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
|
const workflowName = submission.workflow_name || '';
|
|
const searchBody = { filters: workflowName ? [{ field: 'name', exclusive: false, operator: 'EXACT', value: workflowName }] : [], projection: 'internal', sort: [{ field: 'created', direction: 'DESC' }], page: 0, size: 10 };
|
|
|
|
let result;
|
|
try { result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls); } catch (e) { return null; }
|
|
if (result.status !== 200) return null;
|
|
|
|
let uuid = null;
|
|
try {
|
|
const data = JSON.parse(result.body);
|
|
let batches = data._embedded?.workflowBatches || data._embedded?.workflowBatch || data.content || data.data || (Array.isArray(data) ? data : []);
|
|
const batchId = String(submission.ivanti_workflow_batch_id);
|
|
const batch = batches.find(b => String(b.id) === batchId) || batches[0];
|
|
if (batch) uuid = batch.uuid || batch.workflowBatchUuid || batch.batchUuid || batch.groupUuid || batch.group_uuid || batch.workflow_batch_uuid || batch.uid || null;
|
|
} catch (e) { return null; }
|
|
|
|
if (uuid && submission.id) {
|
|
pool.query(`UPDATE ivanti_fp_submissions SET ivanti_workflow_batch_uuid = $1 WHERE id = $2`, [uuid, submission.id]).catch(() => {});
|
|
}
|
|
return uuid;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multer configuration
|
|
// ---------------------------------------------------------------------------
|
|
const uploadStorage = multer.memoryStorage();
|
|
const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (isAllowedFileExtension(file.originalname)) cb(null, true); else cb(new Error(`File type not allowed: ${path.extname(file.originalname)}`)); } }).array('attachments', 10);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router factory
|
|
// ---------------------------------------------------------------------------
|
|
function createIvantiFpWorkflowRouter() {
|
|
const router = express.Router();
|
|
|
|
// GET /documents/search
|
|
router.get('/documents/search', requireAuth(), async (req, res) => {
|
|
const q = (req.query.q || '').trim();
|
|
try {
|
|
let rows;
|
|
if (q) {
|
|
const like = `%${q}%`;
|
|
const result = await pool.query(
|
|
`SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at FROM documents WHERE name ILIKE $1 OR cve_id ILIKE $2 OR vendor ILIKE $3 ORDER BY uploaded_at DESC LIMIT 50`,
|
|
[like, like, like]
|
|
);
|
|
rows = result.rows;
|
|
} else {
|
|
const result = await pool.query(`SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at FROM documents ORDER BY uploaded_at DESC LIMIT 50`);
|
|
rows = result.rows;
|
|
}
|
|
res.json(rows || []);
|
|
} catch (err) {
|
|
console.error('Error searching documents:', err);
|
|
res.status(500).json({ error: 'Database error.' });
|
|
}
|
|
});
|
|
|
|
// POST / — Create FP workflow
|
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
fpUpload(req, res, (multerErr) => {
|
|
if (multerErr) {
|
|
if (multerErr.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' });
|
|
return res.status(400).json({ error: multerErr.message });
|
|
}
|
|
|
|
let findingIds, queueItemIds;
|
|
try { findingIds = JSON.parse(req.body.findingIds || '[]'); queueItemIds = JSON.parse(req.body.queueItemIds || '[]'); }
|
|
catch (e) { return res.status(400).json({ error: 'findingIds and queueItemIds must be valid JSON arrays.' }); }
|
|
|
|
if (!Array.isArray(findingIds) || findingIds.length === 0) return res.status(400).json({ error: 'At least one finding ID is required.' });
|
|
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) return res.status(400).json({ error: 'At least one queue item ID is required.' });
|
|
|
|
let libraryDocIds;
|
|
try { libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]'); } catch (e) { return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); }
|
|
if (!Array.isArray(libraryDocIds)) return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
|
|
for (const id of libraryDocIds) { if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: `Invalid library document ID: ${id}.` }); }
|
|
|
|
const validationErrors = validateFpWorkflowForm(req.body);
|
|
if (Object.keys(validationErrors).length > 0) return res.status(400).json({ success: false, errors: validationErrors });
|
|
|
|
const files = req.files || [];
|
|
for (const file of files) { if (!isAllowedFileExtension(file.originalname)) return res.status(400).json({ error: `File type not allowed: ${file.originalname}` }); }
|
|
|
|
(async () => {
|
|
// Verify queue items
|
|
const { rows: queueRows } = await pool.query(
|
|
`SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id = ANY($1)`, [queueItemIds]
|
|
);
|
|
if (!queueRows || queueRows.length !== queueItemIds.length) return res.status(400).json({ error: 'One or more queue items not found.' });
|
|
for (const row of queueRows) {
|
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'You can only submit your own queue items.' });
|
|
if (row.workflow_type !== 'FP') return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` });
|
|
if (row.status !== 'pending') return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` });
|
|
}
|
|
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
if (!apiKey) return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' });
|
|
|
|
const formFields = buildIvantiFormFields(req.body, findingIds);
|
|
|
|
// Look up library documents
|
|
let libraryDocs = [];
|
|
const libraryAttachmentResults = [];
|
|
if (libraryDocIds.length > 0) {
|
|
const { rows: docRows } = await pool.query(`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id = ANY($1)`, [libraryDocIds]);
|
|
libraryDocs = docRows;
|
|
const foundIds = new Set(libraryDocs.map(d => d.id));
|
|
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
|
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
|
}
|
|
|
|
const libraryFormFiles = [];
|
|
for (const doc of libraryDocs) {
|
|
try { const buffer = fs.readFileSync(doc.file_path); libraryFormFiles.push({ name: 'files', buffer, filename: doc.name }); libraryAttachmentResults.push({ filename: doc.name, success: true, source: 'library', documentId: doc.id }); }
|
|
catch (readErr) { libraryAttachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name }); }
|
|
}
|
|
|
|
const localFormFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
|
|
const formFiles = [...localFormFiles, ...libraryFormFiles];
|
|
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
|
|
|
|
let createResult;
|
|
try { createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls); }
|
|
catch (networkErr) {
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: networkErr.message, findingIds }, ipAddress: req.ip });
|
|
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', step: 'create_workflow', details: networkErr.message });
|
|
}
|
|
|
|
if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) {
|
|
const errorMap = { 401: 'Ivanti API key is invalid or missing.', 419: 'API key lacks workflow creation permissions.', 429: 'Ivanti API rate limit reached.' };
|
|
const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`;
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, findingIds }, ipAddress: req.ip });
|
|
return res.status(createResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg, step: 'create_workflow' });
|
|
}
|
|
|
|
let workflowBatchId;
|
|
try { const createData = JSON.parse(createResult.body); workflowBatchId = createData.id; }
|
|
catch (parseErr) { return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' }); }
|
|
|
|
let workflowBatchUuid = null;
|
|
try { workflowBatchUuid = await resolveWorkflowBatchUuid({ id: null, ivanti_workflow_batch_id: workflowBatchId, ivanti_workflow_batch_uuid: null, workflow_name: req.body.name }, apiKey, clientId, skipTls); } catch (e) {}
|
|
|
|
const localAttachmentResults = files.map(f => ({ filename: f.originalname, success: true, source: 'local' }));
|
|
const allAttachmentResults = [...localAttachmentResults, ...libraryAttachmentResults];
|
|
const totalAttachmentCount = files.length + libraryDocIds.length;
|
|
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO ivanti_fp_submissions (user_id, username, ivanti_workflow_batch_id, ivanti_workflow_batch_uuid, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status, error_message)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
|
|
[req.user.id, req.user.username, workflowBatchId, workflowBatchUuid, null, req.body.name, req.body.reason, req.body.description || null, req.body.expirationDate, req.body.scopeOverride || 'Authorized', JSON.stringify(findingIds), JSON.stringify(queueItemIds), totalAttachmentCount, JSON.stringify(allAttachmentResults), 'success', null]
|
|
);
|
|
} catch (dbErr) { console.error('Failed to insert submission record:', dbErr); }
|
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow', entityId: String(workflowBatchId), details: { workflowName: req.body.name, findingIds, attachmentCount: totalAttachmentCount, status: 'success' }, ipAddress: req.ip });
|
|
|
|
let queueItemsUpdated = 0;
|
|
try {
|
|
const qResult = await pool.query(`UPDATE ivanti_todo_queue SET status='complete', updated_at=NOW() WHERE id = ANY($1) AND user_id=$2`, [queueItemIds, req.user.id]);
|
|
queueItemsUpdated = qResult.rowCount;
|
|
} catch (queueErr) { console.error('Failed to update queue items:', queueErr); }
|
|
|
|
res.json({ success: true, workflowBatchId, queueItemsUpdated, status: 'success' });
|
|
})().catch((unexpectedErr) => { console.error('Unexpected error in FP workflow submission:', unexpectedErr); res.status(500).json({ success: false, error: 'Internal server error.' }); });
|
|
});
|
|
});
|
|
|
|
// GET /submissions
|
|
router.get('/submissions', requireAuth(), async (req, res) => {
|
|
try {
|
|
const { rows: submissions } = await pool.query(`SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]);
|
|
|
|
if (submissions.length > 0) {
|
|
const submissionIds = submissions.map(s => s.id);
|
|
const { rows: historyRows } = await pool.query(`SELECT * FROM ivanti_fp_submission_history WHERE submission_id = ANY($1) ORDER BY created_at ASC`, [submissionIds]);
|
|
const historyMap = {};
|
|
for (const row of historyRows) { if (!historyMap[row.submission_id]) historyMap[row.submission_id] = []; historyMap[row.submission_id].push(row); }
|
|
for (const sub of submissions) { sub.history = historyMap[sub.id] || []; }
|
|
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
if (apiKey) {
|
|
try {
|
|
for (const sub of submissions) {
|
|
if (!sub.workflow_name) continue;
|
|
const searchUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
|
const searchBody = { filters: [{ field: 'name', exclusive: false, operator: 'EXACT', value: sub.workflow_name }], projection: 'internal', sort: [{ field: 'created', direction: 'DESC' }], page: 0, size: 1 };
|
|
const result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls);
|
|
if (result.status === 200) {
|
|
const data = JSON.parse(result.body);
|
|
const batches = data._embedded?.workflowBatches || data._embedded?.workflowBatch || [];
|
|
const batch = batches[0];
|
|
if (batch) { sub.ivanti_rework_note = batch.reworkNote || null; sub.ivanti_approval_note = batch.approvalNote || null; sub.ivanti_current_state_notes = batch.currentStateUserNotes || null; sub.ivanti_previous_state_notes = batch.previousStateUserNotes || null; sub.ivanti_current_state = batch.currentState || null; }
|
|
}
|
|
}
|
|
} catch (e) { console.error('Error enriching submissions with Ivanti notes:', e.message); }
|
|
}
|
|
}
|
|
res.json(submissions);
|
|
} catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); }
|
|
});
|
|
|
|
// PUT /submissions/:id — Edit FP workflow fields
|
|
router.put('/submissions/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
(async () => {
|
|
const submissionId = req.params.id;
|
|
|
|
// 1. Fetch submission and verify ownership
|
|
const { rows: subRows } = await pool.query(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
);
|
|
const submission = subRows[0];
|
|
|
|
if (!submission) {
|
|
return res.status(404).json({ error: 'Submission not found.' });
|
|
}
|
|
if (submission.user_id !== req.user.id) {
|
|
return res.status(403).json({ error: 'You can only edit your own submissions.' });
|
|
}
|
|
|
|
// 2. Lifecycle guard
|
|
if (submission.lifecycle_status === 'approved') {
|
|
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
|
}
|
|
|
|
// 3. Validate body
|
|
const validationErrors = validateFpWorkflowForm(req.body);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
return res.status(400).json({ success: false, errors: validationErrors });
|
|
}
|
|
|
|
// 4. Proxy to Ivanti
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
|
|
if (!apiKey) {
|
|
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' });
|
|
}
|
|
|
|
const scopeMap = { 'Authorized': 'AUTHORIZED', 'None': 'NONE', 'Automated': 'AUTOMATED' };
|
|
const updateUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/update`;
|
|
const updateBody = {
|
|
id: submission.ivanti_workflow_batch_id,
|
|
name: req.body.name,
|
|
reason: req.body.reason,
|
|
description: req.body.description || '',
|
|
expirationDate: req.body.expirationDate,
|
|
overrideControl: scopeMap[req.body.scopeOverride] || 'AUTHORIZED'
|
|
};
|
|
|
|
let ivantiResult;
|
|
try {
|
|
ivantiResult = await ivantiPost(updateUrl, updateBody, apiKey, skipTls);
|
|
} catch (networkErr) {
|
|
logAudit({
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_submission_edit_failed', entityType: 'ivanti_workflow',
|
|
details: { error: networkErr.message, submissionId },
|
|
ipAddress: req.ip
|
|
});
|
|
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message });
|
|
}
|
|
|
|
if (ivantiResult.status !== 200 && ivantiResult.status !== 201 && ivantiResult.status !== 202) {
|
|
const errorMap = {
|
|
401: 'Ivanti API key is invalid or missing. Contact your administrator.',
|
|
419: 'API key lacks permissions for this operation.',
|
|
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
|
|
};
|
|
const errorMsg = ivantiResult.status >= 500
|
|
? 'Ivanti API is temporarily unavailable. Please try again later.'
|
|
: (errorMap[ivantiResult.status] || `Operation failed: ${ivantiResult.status}`);
|
|
return res.status(ivantiResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg });
|
|
}
|
|
|
|
// 5. Determine new lifecycle_status
|
|
let newLifecycleStatus = submission.lifecycle_status;
|
|
if (submission.lifecycle_status === 'rejected' || submission.lifecycle_status === 'rework') {
|
|
newLifecycleStatus = 'resubmitted';
|
|
}
|
|
|
|
// 6. Update local record
|
|
try {
|
|
await pool.query(
|
|
`UPDATE ivanti_fp_submissions
|
|
SET workflow_name = $1, reason = $2, description = $3, expiration_date = $4, scope_override = $5, lifecycle_status = $6, updated_at = NOW()
|
|
WHERE id = $7`,
|
|
[req.body.name, req.body.reason, req.body.description || null, req.body.expirationDate, req.body.scopeOverride || 'Authorized', newLifecycleStatus, submissionId]
|
|
);
|
|
} catch (dbErr) {
|
|
console.error('Failed to update submission record:', dbErr);
|
|
return res.status(500).json({ success: false, error: 'Failed to update local record.' });
|
|
}
|
|
|
|
// 7. Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('fields_updated', {
|
|
changed: {
|
|
name: { from: submission.workflow_name, to: req.body.name },
|
|
reason: { from: submission.reason, to: req.body.reason },
|
|
description: { from: submission.description, to: req.body.description || '' },
|
|
expirationDate: { from: submission.expiration_date, to: req.body.expirationDate },
|
|
scopeOverride: { from: submission.scope_override, to: req.body.scopeOverride || 'Authorized' }
|
|
}
|
|
}, req.user.id, req.user.username);
|
|
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json]
|
|
);
|
|
} catch (histErr) {
|
|
console.error('Failed to insert history row:', histErr);
|
|
}
|
|
|
|
// 8. Audit log
|
|
logAudit({
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_submission_edited', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, workflowName: req.body.name },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
// 9. Return updated record
|
|
const { rows: updatedRows } = await pool.query(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
);
|
|
|
|
res.json({ success: true, submission: updatedRows[0] });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in PUT /submissions/:id:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
// POST /submissions/:id/findings — Map additional findings to existing workflow
|
|
router.post('/submissions/:id/findings', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
(async () => {
|
|
const submissionId = req.params.id;
|
|
const { findingIds, queueItemIds } = req.body;
|
|
|
|
if (!Array.isArray(findingIds) || findingIds.length === 0) {
|
|
return res.status(400).json({ error: 'At least one finding ID is required.' });
|
|
}
|
|
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) {
|
|
return res.status(400).json({ error: 'At least one queue item ID is required.' });
|
|
}
|
|
|
|
// 1. Fetch submission and verify ownership
|
|
const { rows: subRows } = await pool.query(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
);
|
|
const submission = subRows[0];
|
|
|
|
if (!submission) {
|
|
return res.status(404).json({ error: 'Submission not found.' });
|
|
}
|
|
if (submission.user_id !== req.user.id) {
|
|
return res.status(403).json({ error: 'You can only edit your own submissions.' });
|
|
}
|
|
|
|
// 2. Lifecycle guard
|
|
if (submission.lifecycle_status === 'approved') {
|
|
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
|
}
|
|
|
|
// 3. Verify queue items belong to user, are FP type, and pending
|
|
const { rows: queueRows } = await pool.query(
|
|
`SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id = ANY($1)`,
|
|
[queueItemIds]
|
|
);
|
|
|
|
if (queueRows.length !== queueItemIds.length) {
|
|
return res.status(400).json({ error: 'One or more queue items not found.' });
|
|
}
|
|
for (const row of queueRows) {
|
|
if (row.user_id !== req.user.id) {
|
|
return res.status(403).json({ error: 'You can only submit your own queue items.' });
|
|
}
|
|
if (row.workflow_type !== 'FP') {
|
|
return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` });
|
|
}
|
|
if (row.status !== 'pending') {
|
|
return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` });
|
|
}
|
|
}
|
|
|
|
// 4. Proxy to Ivanti map endpoint
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
|
|
if (!apiKey) {
|
|
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' });
|
|
}
|
|
|
|
const mapUuid = await resolveWorkflowBatchUuid(submission, apiKey, clientId, skipTls);
|
|
if (!mapUuid) {
|
|
return res.status(400).json({ success: false, error: 'Could not resolve workflow batch UUID. The workflow may not exist in Ivanti.' });
|
|
}
|
|
|
|
const mapUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(mapUuid)}/map`;
|
|
|
|
const mappedIds = [];
|
|
const failedIds = [];
|
|
for (const fid of findingIds) {
|
|
const mapBody = {
|
|
subject: 'hostFinding',
|
|
filterRequest: {
|
|
filters: [{ field: 'id', exclusive: false, operator: 'EXACT', value: String(fid) }]
|
|
}
|
|
};
|
|
try {
|
|
const result = await ivantiPost(mapUrl, mapBody, apiKey, skipTls);
|
|
if (result.status === 200 || result.status === 201 || result.status === 202) {
|
|
mappedIds.push(fid);
|
|
} else {
|
|
failedIds.push({ id: fid, status: result.status });
|
|
}
|
|
} catch (err) {
|
|
failedIds.push({ id: fid, error: err.message });
|
|
}
|
|
}
|
|
|
|
if (mappedIds.length === 0) {
|
|
return res.status(502).json({ success: false, error: 'Failed to map any findings to the workflow.' });
|
|
}
|
|
|
|
// 5. Merge only successfully mapped finding IDs
|
|
const mergedJson = mergeFindings(submission.finding_ids_json, mappedIds);
|
|
|
|
try {
|
|
await pool.query(
|
|
`UPDATE ivanti_fp_submissions SET finding_ids_json = $1, updated_at = NOW() WHERE id = $2`,
|
|
[mergedJson, submissionId]
|
|
);
|
|
} catch (dbErr) {
|
|
console.error('Failed to update finding_ids_json:', dbErr);
|
|
}
|
|
|
|
// 6. Mark only successfully mapped queue items as complete
|
|
let queueItemsUpdated = 0;
|
|
const mappedSet = new Set(mappedIds.map(String));
|
|
const successQueueIds = queueItemIds.filter((qid, idx) => {
|
|
const queueItem = queueRows.find(r => r.id === qid);
|
|
return queueItem && mappedSet.has(String(findingIds[idx]));
|
|
});
|
|
if (successQueueIds.length > 0) {
|
|
try {
|
|
const qResult = await pool.query(
|
|
`UPDATE ivanti_todo_queue SET status='complete', updated_at=NOW() WHERE id = ANY($1) AND user_id=$2`,
|
|
[successQueueIds, req.user.id]
|
|
);
|
|
queueItemsUpdated = qResult.rowCount;
|
|
} catch (queueErr) {
|
|
console.error('Failed to update queue items:', queueErr);
|
|
}
|
|
}
|
|
|
|
// 7. Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('findings_added', {
|
|
addedFindingIds: mappedIds,
|
|
failedFindingIds: failedIds.map(f => f.id || f),
|
|
queueItemIds: successQueueIds
|
|
}, req.user.id, req.user.username);
|
|
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json]
|
|
);
|
|
} catch (histErr) {
|
|
console.error('Failed to insert history row:', histErr);
|
|
}
|
|
|
|
// 8. Audit log
|
|
logAudit({
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_findings_added', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, addedFindingIds: findingIds, queueItemsUpdated },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ success: true, addedFindings: mappedIds, failedFindings: failedIds, queueItemsUpdated });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in POST /submissions/:id/findings:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
// POST /submissions/:id/attachments — Upload additional attachments
|
|
router.post('/submissions/:id/attachments', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
fpUpload(req, res, (multerErr) => {
|
|
if (multerErr) {
|
|
if (multerErr.code === 'LIMIT_FILE_SIZE') {
|
|
return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' });
|
|
}
|
|
return res.status(400).json({ error: multerErr.message });
|
|
}
|
|
|
|
const files = req.files || [];
|
|
|
|
let libraryDocIds;
|
|
try { libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]'); }
|
|
catch (e) { return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); }
|
|
if (!Array.isArray(libraryDocIds)) return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
|
|
for (const id of libraryDocIds) { if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: `Invalid library document ID: ${id}.` }); }
|
|
|
|
if (files.length === 0 && libraryDocIds.length === 0) {
|
|
return res.status(400).json({ error: 'At least one file or library document is required.' });
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (!isAllowedFileExtension(file.originalname)) {
|
|
return res.status(400).json({ error: `File type not allowed: ${file.originalname}` });
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
const submissionId = req.params.id;
|
|
|
|
const { rows: subRows } = await pool.query(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
);
|
|
const submission = subRows[0];
|
|
|
|
if (!submission) return res.status(404).json({ error: 'Submission not found.' });
|
|
if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only edit your own submissions.' });
|
|
if (submission.lifecycle_status === 'approved') return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
|
|
|
// Look up library documents
|
|
let libraryDocs = [];
|
|
if (libraryDocIds.length > 0) {
|
|
const { rows: docRows } = await pool.query(
|
|
`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id = ANY($1)`, [libraryDocIds]
|
|
);
|
|
libraryDocs = docRows;
|
|
const foundIds = new Set(libraryDocs.map(d => d.id));
|
|
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
|
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
|
}
|
|
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
|
|
if (!apiKey) return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' });
|
|
|
|
const attachUuid = await resolveWorkflowBatchUuid(submission, apiKey, clientId, skipTls);
|
|
if (!attachUuid) return res.status(400).json({ success: false, error: 'Could not resolve workflow batch UUID.' });
|
|
|
|
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(attachUuid)}/attach`;
|
|
const attachmentResults = [];
|
|
|
|
// Upload local files
|
|
for (const f of files) {
|
|
try {
|
|
const formFiles = [{ name: 'file', buffer: f.buffer, filename: f.originalname, contentType: f.mimetype || 'application/octet-stream' }];
|
|
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
|
|
const success = result.status === 200 || result.status === 201 || result.status === 202;
|
|
attachmentResults.push({ filename: f.originalname, success, source: 'local', ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
|
|
} catch (uploadErr) {
|
|
attachmentResults.push({ filename: f.originalname, success: false, source: 'local', error: uploadErr.message });
|
|
}
|
|
}
|
|
|
|
// Upload library files
|
|
for (const doc of libraryDocs) {
|
|
let buffer;
|
|
try { buffer = fs.readFileSync(doc.file_path); }
|
|
catch (readErr) { attachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name }); continue; }
|
|
try {
|
|
const formFiles = [{ name: 'file', buffer, filename: doc.name, contentType: doc.mime_type || 'application/octet-stream' }];
|
|
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
|
|
const success = result.status === 200 || result.status === 201 || result.status === 202;
|
|
attachmentResults.push({ filename: doc.name, success, source: 'library', documentId: doc.id, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
|
|
} catch (uploadErr) {
|
|
attachmentResults.push({ filename: doc.name, success: false, source: 'library', documentId: doc.id, error: uploadErr.message });
|
|
}
|
|
}
|
|
|
|
// Update attachment_count and attachment_results_json
|
|
const existingResults = JSON.parse(submission.attachment_results_json || '[]');
|
|
const allResults = [...existingResults, ...attachmentResults];
|
|
const successCount = attachmentResults.filter(r => r.success).length;
|
|
const newAttachmentCount = (submission.attachment_count || 0) + successCount;
|
|
|
|
try {
|
|
await pool.query(
|
|
`UPDATE ivanti_fp_submissions SET attachment_count = $1, attachment_results_json = $2, updated_at = NOW() WHERE id = $3`,
|
|
[newAttachmentCount, JSON.stringify(allResults), submissionId]
|
|
);
|
|
} catch (dbErr) { console.error('Failed to update attachment records:', dbErr); }
|
|
|
|
// Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('attachments_added', { files: attachmentResults }, req.user.id, req.user.username);
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json]
|
|
);
|
|
} catch (histErr) { console.error('Failed to insert history row:', histErr); }
|
|
|
|
logAudit({
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, attachmentResults, libraryDocCount: libraryDocIds.length },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
const allSucceeded = attachmentResults.every(r => r.success);
|
|
res.json({ success: true, attachmentResults, status: allSucceeded ? 'success' : 'partial' });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in POST /submissions/:id/attachments:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
});
|
|
|
|
// PATCH /submissions/:id/dismiss — Dismiss a rejected submission
|
|
router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
(async () => {
|
|
const submissionId = req.params.id;
|
|
|
|
const { rows: subRows } = await pool.query(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
);
|
|
const submission = subRows[0];
|
|
|
|
if (!submission) return res.status(404).json({ error: 'Submission not found.' });
|
|
if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only dismiss your own submissions.' });
|
|
if (submission.lifecycle_status !== 'rejected') return res.status(400).json({ error: 'Only rejected submissions can be dismissed.' });
|
|
|
|
try {
|
|
await pool.query(
|
|
`UPDATE ivanti_fp_submissions SET dismissed_at = NOW() WHERE id = $1`,
|
|
[submissionId]
|
|
);
|
|
} catch (dbErr) {
|
|
console.error('Failed to set dismissed_at:', dbErr);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
logAudit({
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_submission_dismissed', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ success: true });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in PATCH /submissions/:id/dismiss:', unexpectedErr);
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
// PATCH /submissions/:id/status — Update lifecycle status
|
|
router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
(async () => {
|
|
const submissionId = req.params.id;
|
|
const newStatus = req.body.lifecycle_status;
|
|
|
|
const { rows: subRows } = await pool.query(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId]
|
|
);
|
|
const submission = subRows[0];
|
|
|
|
if (!submission) return res.status(404).json({ error: 'Submission not found.' });
|
|
if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only edit your own submissions.' });
|
|
|
|
const transition = validateLifecycleTransition(submission.lifecycle_status, newStatus);
|
|
if (!transition.valid) return res.status(400).json({ error: transition.error });
|
|
|
|
const previousStatus = submission.lifecycle_status;
|
|
|
|
try {
|
|
await pool.query(
|
|
`UPDATE ivanti_fp_submissions SET lifecycle_status = $1, updated_at = NOW() WHERE id = $2`,
|
|
[newStatus, submissionId]
|
|
);
|
|
} catch (dbErr) {
|
|
console.error('Failed to update lifecycle status:', dbErr);
|
|
return res.status(500).json({ success: false, error: 'Failed to update status.' });
|
|
}
|
|
|
|
const historyEntry = buildSubmissionHistoryEntry('status_changed', { from: previousStatus, to: newStatus }, req.user.id, req.user.username);
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json]
|
|
);
|
|
} catch (histErr) { console.error('Failed to insert history row:', histErr); }
|
|
|
|
logAudit({
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_status_changed', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, from: previousStatus, to: newStatus },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ success: true, previousStatus, newStatus });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in PATCH /submissions/:id/status:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
module.exports = createIvantiFpWorkflowRouter;
|
|
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
|
|
module.exports.buildIvantiFormFields = buildIvantiFormFields;
|
|
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
|
|
module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
|
module.exports.validateLifecycleTransition = validateLifecycleTransition;
|
|
module.exports.mergeFindings = mergeFindings;
|
|
module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry;
|
|
module.exports.filterVisibleSubmissions = filterVisibleSubmissions;
|
|
module.exports.shouldShowDismissButton = shouldShowDismissButton;
|