Files
cve-dashboard/backend/routes/ivantiFpWorkflow.js
Jordan Ramos 19b5009010 Improve FP workflow error messages — include Ivanti API response body
When the Ivanti API returns a non-success status, the error message
now includes the actual response body from Ivanti instead of just
the HTTP status code. This makes troubleshooting much easier since
you can see what Ivanti rejected (e.g. invalid field, too many
attachments, malformed request).
2026-05-22 11:51:10 -06:00

1191 lines
64 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 /api/ivanti/fp-workflow/documents/search
*
* Searches the documents library by name, CVE ID, or vendor.
* Returns up to 50 results ordered by upload date descending.
*
* @query {string} q — Optional search term; matches against name, cve_id, or vendor (ILIKE)
* @returns {Array<Object>} Array of document metadata objects
* - id {number}
* - cve_id {string}
* - vendor {string}
* - name {string}
* - type {string}
* - file_size {number}
* - mime_type {string}
* - uploaded_at {string}
* @error 500 Database error
*/
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 /api/ivanti/fp-workflow
*
* Creates a new False Positive workflow in Ivanti and records the submission locally.
* Validates queue items belong to the user, are FP type, and are pending.
* Uploads local files and library documents as attachments.
* Marks associated queue items as complete on success.
* Requires Admin or Standard_User group.
*
* @body {Object} multipart/form-data
* - findingIds {string} JSON array of Ivanti finding IDs
* - queueItemIds {string} JSON array of todo queue item IDs
* - libraryDocIds {string} Optional JSON array of document library IDs to attach
* - name {string} Workflow name (required, max 255 chars)
* - reason {string} Reason for false positive (required)
* - description {string} Optional description (max 2000 chars)
* - expirationDate {string} Required, must be future date within 120 days
* - scopeOverride {string} Optional: Authorized, None, or Automated
* - attachments {File[]} Optional uploaded files (max 10, each max 10 MB)
* @returns {Object}
* - success {boolean}
* - workflowBatchId {number} Ivanti workflow batch ID
* - queueItemsUpdated {number} Count of queue items marked complete
* - status {string} "success"
* @error 400 Invalid input, queue item validation failure
* @error 403 Queue items belong to another user
* @error 429 Ivanti API rate limit
* @error 500 Ivanti API key not configured
* @error 502 Ivanti API connection or response failure
*/
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.' };
let errorMsg = errorMap[createResult.status];
if (!errorMsg) {
// Try to extract detail from the Ivanti response body
let bodyDetail = '';
try {
const parsed = JSON.parse(createResult.body);
bodyDetail = parsed.message || parsed.error || parsed.detail || JSON.stringify(parsed);
} catch (_) {
bodyDetail = (createResult.body || '').slice(0, 500);
}
errorMsg = `Workflow creation failed (${createResult.status}): ${bodyDetail || 'No details returned by Ivanti API.'}`;
}
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, responseBody: (createResult.body || '').slice(0, 1000), 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 /api/ivanti/fp-workflow/submissions
*
* Returns all FP workflow submissions belonging to the authenticated user,
* enriched with Ivanti workflow state (rework notes, approval notes, current state).
* Automatically syncs lifecycle_status from Ivanti currentState when it differs.
* Includes submission history entries for each submission.
*
* @query None
* @returns {Array<Object>} Array of submission objects
* - id {number}
* - user_id {number}
* - username {string}
* - ivanti_workflow_batch_id {number}
* - ivanti_workflow_batch_uuid {string|null}
* - workflow_name {string}
* - reason {string}
* - description {string|null}
* - expiration_date {string}
* - scope_override {string}
* - finding_ids_json {string}
* - queue_item_ids_json {string}
* - attachment_count {number}
* - lifecycle_status {string} submitted|approved|rejected|rework|resubmitted
* - dismissed_at {string|null}
* - requeued_at {string|null}
* - history {Array<Object>} Submission history entries
* - ivanti_rework_note {string|null} Enriched from Ivanti API
* - ivanti_approval_note {string|null} Enriched from Ivanti API
* - ivanti_current_state {string|null} Enriched from Ivanti API
* @error 500 Internal server error
*/
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); }
// Sync lifecycle_status from Ivanti currentState when it differs
const STATE_MAP = { 'APPROVED': 'approved', 'REJECTED': 'rejected', 'REWORK': 'rework' };
for (const sub of submissions) {
if (!sub.ivanti_current_state) continue;
const mappedStatus = STATE_MAP[sub.ivanti_current_state.toUpperCase()];
if (mappedStatus && mappedStatus !== sub.lifecycle_status) {
try {
await pool.query(
`UPDATE ivanti_fp_submissions SET lifecycle_status = $1, updated_at = NOW() WHERE id = $2`,
[mappedStatus, sub.id]
);
sub.lifecycle_status = mappedStatus;
} catch (syncErr) { console.error(`Failed to sync lifecycle_status for submission ${sub.id}:`, syncErr.message); }
}
}
}
}
res.json(submissions);
} catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); }
});
/**
* PUT /api/ivanti/fp-workflow/submissions/:id
*
* Edits the workflow fields of an existing FP submission. Proxies the update
* to the Ivanti API, then updates the local record. Transitions lifecycle_status
* to "resubmitted" if currently rejected or rework.
* Requires Admin or Standard_User group.
*
* @param {string} id — Submission ID (URL parameter)
* @body {Object}
* - name {string} Workflow name (required, max 255 chars)
* - reason {string} Reason (required)
* - description {string} Optional (max 2000 chars)
* - expirationDate {string} Required, future date within 120 days
* - scopeOverride {string} Optional: Authorized, None, or Automated
* @returns {Object}
* - success {boolean}
* - submission {Object} The updated submission record
* @error 400 Validation errors or submission is finalized
* @error 403 Submission belongs to another user
* @error 404 Submission not found
* @error 429 Ivanti API rate limit
* @error 500 Ivanti API key not configured or local DB failure
* @error 502 Ivanti API connection or response failure
*/
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 /api/ivanti/fp-workflow/submissions/:id/findings
*
* Maps additional findings to an existing FP workflow in Ivanti. Resolves the
* workflow batch UUID, then maps each finding individually. Successfully mapped
* findings are merged into the submission's finding_ids_json and their queue items
* are marked complete.
* Requires Admin or Standard_User group.
*
* @param {string} id — Submission ID (URL parameter)
* @body {Object}
* - findingIds {Array<number|string>} Finding IDs to map (at least one required)
* - queueItemIds {Array<number>} Corresponding queue item IDs (at least one required)
* @returns {Object}
* - success {boolean}
* - addedFindings {Array} Successfully mapped finding IDs
* - failedFindings {Array} Finding IDs that failed to map
* - queueItemsUpdated {number} Count of queue items marked complete
* @error 400 Invalid input, queue item validation, or UUID resolution failure
* @error 403 Submission or queue items belong to another user
* @error 404 Submission not found
* @error 500 Ivanti API key not configured
* @error 502 All findings failed to map
*/
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 /api/ivanti/fp-workflow/submissions/:id/attachments
*
* Uploads additional attachments (local files and/or library documents) to an
* existing FP workflow in Ivanti. Updates the local submission's attachment count
* and results.
* Requires Admin or Standard_User group.
*
* @param {string} id — Submission ID (URL parameter)
* @body {Object} multipart/form-data
* - attachments {File[]} Local files to upload (max 10, each max 10 MB)
* - libraryDocIds {string} Optional JSON array of document library IDs
* @returns {Object}
* - success {boolean}
* - attachmentResults {Array<Object>} Per-file upload results with filename, success, source
* - status {string} "success" if all uploaded, "partial" if some failed
* @error 400 No files provided, invalid file type, or submission is finalized
* @error 403 Submission belongs to another user
* @error 404 Submission not found
* @error 500 Ivanti API key not configured
*/
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 /api/ivanti/fp-workflow/submissions/:id/dismiss
*
* Dismisses a rejected FP submission by setting dismissed_at timestamp.
* Only rejected submissions can be dismissed.
* Requires Admin or Standard_User group.
*
* @param {string} id — Submission ID (URL parameter)
* @body None
* @returns {Object} { success: true }
* @error 400 Submission is not in rejected status
* @error 403 Submission belongs to another user
* @error 404 Submission not found
* @error 500 Internal server error
*/
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.' });
});
});
/**
* POST /api/ivanti/fp-workflow/submissions/:id/requeue
*
* Re-queues findings from a rejected FP submission into the todo queue
* under a specified target workflow type. Creates new pending queue items
* for each finding referenced in the submission's queue_item_ids_json.
* Requires Admin or Standard_User group.
*
* @param {string} id — Submission ID (URL parameter)
* @body {Object}
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
* @returns {Object}
* - success {boolean}
* - items {Array<Object>} Newly created queue items with parsed cves
* - count {number} Number of items created
* @error 400 Invalid input, submission not rejected, or already re-queued
* @error 403 Submission belongs to another user
* @error 404 Submission not found
* @error 500 Internal server error
*/
router.post('/submissions/:id/requeue', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => {
const submissionId = req.params.id;
const { workflow_type, vendor } = req.body;
// Validate workflow_type
const VALID_REQUEUE_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'];
const INVENTORY_TYPES = ['CARD', 'GRANITE'];
if (!VALID_REQUEUE_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' });
}
// Validate vendor for FP/Archer/DECOM
if (!INVENTORY_TYPES.includes(workflow_type)) {
if (!vendor || typeof vendor !== 'string' || vendor.trim().length === 0) {
return res.status(400).json({ error: 'vendor is required for FP, Archer, and DECOM workflows.' });
}
if (vendor.trim().length > 200) {
return res.status(400).json({ error: 'vendor must be 200 characters or fewer.' });
}
}
const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim();
// Fetch submission
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 re-queue your own submissions.' });
if (submission.lifecycle_status !== 'rejected') return res.status(400).json({ error: 'Only rejected submissions can be re-queued.' });
if (submission.requeued_at) return res.status(400).json({ error: 'Findings from this submission have already been re-queued.' });
// Parse original queue item IDs
let queueItemIds = [];
try {
queueItemIds = JSON.parse(submission.queue_item_ids_json || '[]');
} catch (e) { /* ignore parse errors */ }
// Parse finding IDs (always available, even for submissions created outside dashboard)
let findingIds = [];
try {
findingIds = JSON.parse(submission.finding_ids_json || '[]');
} catch (e) { /* ignore */ }
if ((!Array.isArray(queueItemIds) || queueItemIds.length === 0) &&
(!Array.isArray(findingIds) || findingIds.length === 0)) {
return res.status(400).json({ error: 'No findings associated with this submission.' });
}
// Fetch original queue items to get finding data (if they still exist)
let findingsToQueue = [];
if (queueItemIds.length > 0) {
const { rows: originalItems } = await pool.query(
`SELECT finding_id, finding_title, cves_json, ip_address, hostname FROM ivanti_todo_queue WHERE id = ANY($1)`,
[queueItemIds]
);
findingsToQueue = originalItems;
}
// Fallback: if original queue items were deleted or never existed,
// use finding_ids_json to look up finding data from ivanti_findings
if (findingsToQueue.length === 0 && findingIds.length > 0) {
const { rows: findings } = await pool.query(
`SELECT id AS finding_id, title AS finding_title, cves, ip_address, host_name AS hostname FROM ivanti_findings WHERE id = ANY($1)`,
[findingIds.map(String)]
);
findingsToQueue = findings.map(f => ({
...f,
cves_json: Array.isArray(f.cves) ? JSON.stringify(f.cves) : null,
}));
// Last resort: create items with just the finding IDs (minimal data)
if (findingsToQueue.length === 0) {
findingsToQueue = findingIds.map(id => ({
finding_id: String(id),
finding_title: null,
cves_json: null,
ip_address: null,
hostname: null,
}));
}
}
// INSERT new pending queue items for each finding
const newItems = [];
for (const item of findingsToQueue) {
const { rows: inserted } = await pool.query(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[req.user.id, item.finding_id, item.finding_title, item.cves_json, item.ip_address, item.hostname, vendorVal, workflow_type]
);
newItems.push(inserted[0]);
}
// UPDATE submission to mark as requeued
await pool.query(
`UPDATE ivanti_fp_submissions SET requeued_at = NOW() WHERE id = $1`,
[submissionId]
);
// Audit log (fire-and-forget)
const auditFindingIds = findingsToQueue.map(i => i.finding_id).filter(Boolean);
logAudit({
userId: req.user.id, username: req.user.username,
action: 'fp_submission_requeued', entityType: 'ivanti_fp_submissions',
entityId: String(submission.id),
details: { target_workflow_type: workflow_type, items_created: newItems.length, finding_ids: auditFindingIds },
ipAddress: req.ip
});
// Return items with parsed cves
const itemsWithCves = newItems.map(i => ({
...i,
cves: i.cves_json ? JSON.parse(i.cves_json) : []
}));
res.status(201).json({ success: true, items: itemsWithCves, count: newItems.length });
})().catch((unexpectedErr) => {
console.error('Unexpected error in POST /submissions/:id/requeue:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
});
/**
* PATCH /api/ivanti/fp-workflow/submissions/:id/status
*
* Manually updates the lifecycle status of an FP submission.
* Validates the transition is allowed (e.g., approved submissions cannot be changed).
* Requires Admin or Standard_User group.
*
* @param {string} id — Submission ID (URL parameter)
* @body {Object}
* - lifecycle_status {string} New status. One of: submitted, approved, rejected, rework, resubmitted
* @returns {Object}
* - success {boolean}
* - previousStatus {string}
* - newStatus {string}
* @error 400 Invalid transition or invalid status value
* @error 403 Submission belongs to another user
* @error 404 Submission not found
* @error 500 Internal server error
*/
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;