// routes/ivantiFpWorkflow.js const express = require('express'); const multer = require('multer'); const path = require('path'); const { requireGroup } = require('../middleware/auth'); const { ivantiFormPost, ivantiPost, ivantiMultipartPost } = 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' ]); /** * Returns true if the filename has an allowed extension (case-insensitive). */ function isAllowedFileExtension(filename) { if (!filename || typeof filename !== 'string') return false; const ext = path.extname(filename).toLowerCase(); return ALLOWED_EXTENSIONS.has(ext); } /** * Validates the FP workflow form body. * Returns {} if valid, or { fieldName: 'error message' } for each invalid field. */ function validateFpWorkflowForm(body) { const errors = {}; // name: required, non-empty, max 255 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.'; } // reason: required, non-empty if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) { errors.reason = 'Reason is required.'; } // description: optional, max 2000 if provided 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.'; } } // expirationDate: required, valid date, strictly after today 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.'; } } } return errors; } /** * Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint. * Format: { subject, filterRequest: { filters } } */ function buildSubjectFilterRequest(findingIds) { return JSON.stringify({ subject: 'hostFinding', filterRequest: { filters: [{ field: 'id', exclusive: false, operator: 'IN', value: findingIds.map(id => String(id)).join(',') }] } }); } /** * Builds the multipart form fields array for the Ivanti FP workflow request. */ 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' ]); /** * Validates whether a lifecycle status transition is allowed. * Returns { valid: true } or { valid: false, error: string }. */ 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 }; } /** * Merges existing finding IDs (JSON string) with new IDs (array), deduplicates, * and returns the merged array as a JSON string. */ function mergeFindings(existingJson, newIds) { const existing = JSON.parse(existingJson || '[]'); const merged = [...new Set([...existing, ...newIds])]; return JSON.stringify(merged); } /** * Builds a submission history entry object. * The caller is responsible for setting submission_id on the returned object. */ 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() }; } // --------------------------------------------------------------------------- // Resolve workflow batch UUID — looks it up via Ivanti search if not stored locally // --------------------------------------------------------------------------- async function resolveWorkflowBatchUuid(db, submission, apiKey, clientId, skipTls) { // Return cached UUID if available if (submission.ivanti_workflow_batch_uuid) return submission.ivanti_workflow_batch_uuid; // Search Ivanti for the workflow batch by numeric ID to get the UUID const searchUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`; const searchBody = { filters: [{ field: 'id', exclusive: false, operator: 'EXACT', value: String(submission.ivanti_workflow_batch_id) }], projection: 'basic', sort: [{ field: 'id', direction: 'ASC' }], page: 0, size: 1 }; const result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls); if (result.status !== 200) return null; let uuid = null; try { const data = JSON.parse(result.body); const batch = (data._embedded?.workflowBatches || data.content || [])[0]; uuid = batch?.uuid || batch?.workflowBatchUuid || null; } catch { return null; } // Cache the UUID locally for future use if (uuid) { db.run( `UPDATE ivanti_fp_submissions SET ivanti_workflow_batch_uuid = ? WHERE id = ?`, [uuid, submission.id], (err) => { if (err) console.error('Failed to cache workflow batch UUID:', err); } ); } return uuid; } // --------------------------------------------------------------------------- // Multer configuration // --------------------------------------------------------------------------- const uploadStorage = multer.memoryStorage(); const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB per file 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); // up to 10 files // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- function createIvantiFpWorkflowRouter(db, requireAuth) { const router = express.Router(); /** * POST /api/ivanti/fp-workflow * * Creates a False Positive workflow batch in the Ivanti/RiskSense API, * optionally uploads file attachments, records the submission locally, * and marks the associated queue items as complete. * * Content-Type: multipart/form-data * * @param {string} req.body.name - Workflow name (required, max 255 chars) * @param {string} req.body.reason - Reason for the FP determination (required) * @param {string} [req.body.description] - Additional description (optional, max 2000 chars) * @param {string} req.body.expirationDate - ISO date string, must be a future date (required) * @param {string} [req.body.scopeOverride] - "Authorized" (default) or "None" * @param {string} req.body.findingIds - JSON-encoded array of Ivanti finding IDs * @param {string} req.body.queueItemIds - JSON-encoded array of local queue item IDs * @param {File[]} [req.files] - Up to 10 file attachments (max 10 MB each); * allowed extensions: .pdf .png .jpg .jpeg .gif * .doc .docx .xlsx .csv .txt .zip * * @returns {object} 200 - Success * { success: true, workflowBatchId: number, generatedId: string, * attachmentResults: Array<{ filename: string, success: boolean, error?: string }>, * queueItemsUpdated: number, status: 'success' | 'partial' } * @returns {object} 400 - Validation error * { error: string } or { success: false, errors: { [field]: string } } * @returns {object} 403 - Queue item ownership violation * { error: string } * @returns {object} 429 - Ivanti rate limit * { success: false, error: string, step: 'create_workflow' } * @returns {object} 500 - Server configuration error * { success: false, error: string, step: 'create_workflow' } * @returns {object} 502 - Ivanti API error * { success: false, error: string, step: 'create_workflow', details?: string } */ router.post('/', requireAuth(db), 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 }); } // --- Parse JSON-encoded arrays from the multipart body --- 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.' }); } // --- Validate form fields --- const validationErrors = validateFpWorkflowForm(req.body); if (Object.keys(validationErrors).length > 0) { return res.status(400).json({ success: false, errors: validationErrors }); } // --- Validate file extensions (belt-and-suspenders with Multer filter) --- 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}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` }); } } // --- Verify queue items belong to user, are FP type, and pending --- const placeholders = queueItemIds.map(() => '?').join(','); db.all( `SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id IN (${placeholders})`, queueItemIds, (err, rows) => { if (err) { console.error('Error verifying queue items:', err); return res.status(500).json({ error: 'Internal server error.' }); } // Check all items were found if (!rows || rows.length !== queueItemIds.length) { return res.status(400).json({ error: 'One or more queue items not found.' }); } // Check ownership, type, and status for (const row of rows) { 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.` }); } } // --- Validation passed — submit to Ivanti API --- (async () => { 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' }); } // 1. Build form fields and call Ivanti API (multipart/form-data) const formFields = buildIvantiFormFields(req.body, findingIds); const formFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname })); const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`; let createResult; try { createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls); } catch (networkErr) { logAudit(db, { 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 }); } // Handle error responses from Ivanti 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. Please try again in a few minutes.' }; const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`; const errorResponse = { success: false, error: errorMsg, step: 'create_workflow' }; if (!errorMap[createResult.status]) { errorResponse.details = createResult.body; } logAudit(db, { 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(errorResponse); } // 2. Parse workflow batch response — API returns { id, created } let workflowBatchId; try { const createData = JSON.parse(createResult.body); workflowBatchId = createData.id; } catch (parseErr) { logAudit(db, { userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: 'Failed to parse Ivanti response', responseBody: createResult.body }, ipAddress: req.ip }); return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' }); } // 3. Determine submission status (files sent inline, so success if we got here) const status = 'success'; // 3.5. Try to resolve the UUID for future map/attach operations let workflowBatchUuid = null; try { workflowBatchUuid = await resolveWorkflowBatchUuid(db, { id: null, ivanti_workflow_batch_id: workflowBatchId, ivanti_workflow_batch_uuid: null }, apiKey, clientId, skipTls); } catch (e) { console.error('Failed to resolve workflow batch UUID after creation:', e); } // 4. Insert submission record try { await new Promise((resolve, reject) => { db.run( `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ req.user.id, req.user.username, workflowBatchId, workflowBatchUuid, null, // generatedId not returned by this endpoint req.body.name, req.body.reason, req.body.description || null, req.body.expirationDate, req.body.scopeOverride || 'Authorized', JSON.stringify(findingIds), JSON.stringify(queueItemIds), files.length, JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))), status, null ], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (dbErr) { console.error('Failed to insert submission record:', dbErr); // Don't fail the response — the Ivanti workflow was created } // 5. Log audit entry logAudit(db, { 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: files.length, status }, ipAddress: req.ip }); // 6. Mark queue items as complete let queueItemsUpdated = 0; try { const queuePlaceholders = queueItemIds.map(() => '?').join(','); queueItemsUpdated = await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`, [...queueItemIds, req.user.id], function (err) { if (err) reject(err); else resolve(this.changes); } ); }); } catch (queueErr) { console.error('Failed to update queue items:', queueErr); // Don't fail — workflow was created } // 7. Return response res.json({ success: true, workflowBatchId, queueItemsUpdated, status }); })().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-submissions * * Returns the authenticated user's FP submission records, each enriched * with its submission history entries. * * @returns {Array} 200 - Array of FP submission records, each with: * { id, user_id, username, ivanti_workflow_batch_id, ivanti_workflow_batch_uuid, * workflow_name, reason, description, expiration_date, scope_override, * finding_ids_json, queue_item_ids_json, attachment_count, * attachment_results_json, status, lifecycle_status, error_message, * created_at, updated_at, history: Array } * @returns {object} 500 - Internal server error * { error: string } */ router.get('/submissions', requireAuth(db), (req, res) => { (async () => { try { const submissions = await new Promise((resolve, reject) => { db.all( `SELECT * FROM ivanti_fp_submissions WHERE user_id = ? ORDER BY created_at DESC`, [req.user.id], (err, rows) => { if (err) reject(err); else resolve(rows || []); } ); }); // Fetch history for all submissions in one query if there are any if (submissions.length > 0) { const submissionIds = submissions.map(s => s.id); const placeholders = submissionIds.map(() => '?').join(','); const historyRows = await new Promise((resolve, reject) => { db.all( `SELECT * FROM ivanti_fp_submission_history WHERE submission_id IN (${placeholders}) ORDER BY created_at ASC`, submissionIds, (err, rows) => { if (err) reject(err); else resolve(rows || []); } ); }); // Group history by submission_id 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] || []; } } else { // No submissions, nothing to do } res.json(submissions); } catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); } })().catch((unexpectedErr) => { console.error('Unexpected error in GET /submissions:', unexpectedErr); res.status(500).json({ error: 'Internal server error.' }); }); }); /** * PUT /api/ivanti/fp-submissions/:id * * Updates form fields of an existing FP submission and proxies the * changes to the Ivanti update endpoint. Records the edit in * submission history and audit log. Automatically transitions * lifecycle_status to "resubmitted" when editing a rejected/rework * submission. * * @param {string} req.params.id - Local FP submission ID * @param {string} req.body.name - Workflow name (required, max 255 chars) * @param {string} req.body.reason - Reason for the FP determination (required) * @param {string} [req.body.description] - Additional description (optional, max 2000 chars) * @param {string} req.body.expirationDate - ISO date string, must be a future date (required) * @param {string} [req.body.scopeOverride] - "Authorized" (default), "None", or "Automated" * * @returns {object} 200 - Success * { success: true, submission: object } * @returns {object} 400 - Validation error or lifecycle guard * { success: false, errors: { [field]: string } } or { error: string } * @returns {object} 403 - Ownership violation * { error: string } * @returns {object} 404 - Submission not found * { error: string } * @returns {object} 429 - Ivanti rate limit * { success: false, error: string } * @returns {object} 500 - Server/database error * { success: false, error: string } * @returns {object} 502 - Ivanti API error * { success: false, error: string, details?: string } */ router.put('/submissions/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; // 1. Fetch submission and verify ownership const submission = await new Promise((resolve, reject) => { db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { if (err) reject(err); else resolve(row); }); }); 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(db, { 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 const now = new Date().toISOString(); try { await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_fp_submissions SET workflow_name = ?, reason = ?, description = ?, expiration_date = ?, scope_override = ?, lifecycle_status = ?, updated_at = ? WHERE id = ?`, [ req.body.name, req.body.reason, req.body.description || null, req.body.expirationDate, req.body.scopeOverride || 'Authorized', newLifecycleStatus, now, submissionId ], (err) => { if (err) reject(err); else resolve(); } ); }); } 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 new Promise((resolve, reject) => { db.run( `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (histErr) { console.error('Failed to insert history row:', histErr); } // 8. Audit log logAudit(db, { 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 updatedRecord = await new Promise((resolve, reject) => { db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { if (err) reject(err); else resolve(row); }); }); res.json({ success: true, submission: updatedRecord }); })().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-submissions/:id/findings * * Maps additional findings to an existing FP workflow batch via the * Ivanti map endpoint. Merges the new finding IDs into the local * submission record, marks the corresponding queue items as complete, * and records the change in submission history and audit log. * * @param {string} req.params.id - Local FP submission ID * @param {string[]} req.body.findingIds - Array of Ivanti finding IDs to add * @param {number[]} req.body.queueItemIds - Array of local queue item IDs (must be FP, pending, owned by user) * * @returns {object} 200 - Success * { success: true, addedFindings: string[], queueItemsUpdated: number } * @returns {object} 400 - Validation error, lifecycle guard, or UUID resolution failure * { error: string } or { success: false, error: string } * @returns {object} 403 - Ownership violation * { error: string } * @returns {object} 404 - Submission not found * { error: string } * @returns {object} 429 - Ivanti rate limit * { success: false, error: string } * @returns {object} 500 - Server error * { success: false, error: string } * @returns {object} 502 - Ivanti API error * { success: false, error: string, details?: string } */ router.post('/submissions/:id/findings', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; const { findingIds, queueItemIds } = req.body; // Validate 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.' }); } // 1. Fetch submission and verify ownership const submission = await new Promise((resolve, reject) => { db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { if (err) reject(err); else resolve(row); }); }); 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 placeholders = queueItemIds.map(() => '?').join(','); const queueRows = await new Promise((resolve, reject) => { db.all( `SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id IN (${placeholders})`, queueItemIds, (err, rows) => { if (err) reject(err); else resolve(rows || []); } ); }); 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(db, 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 formFields = [{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) }]; let mapResult; try { mapResult = await ivantiFormPost(mapUrl, formFields, [], apiKey, skipTls); } catch (networkErr) { logAudit(db, { userId: req.user.id, username: req.user.username, action: 'ivanti_fp_findings_add_failed', entityType: 'ivanti_workflow', details: { error: networkErr.message, submissionId, findingIds }, ipAddress: req.ip }); return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message }); } if (mapResult.status !== 200 && mapResult.status !== 201 && mapResult.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 = mapResult.status >= 500 ? 'Ivanti API is temporarily unavailable. Please try again later.' : (errorMap[mapResult.status] || `Operation failed: ${mapResult.status}`); return res.status(mapResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg }); } // 5. Merge finding IDs const mergedJson = mergeFindings(submission.finding_ids_json, findingIds); const now = new Date().toISOString(); try { await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_fp_submissions SET finding_ids_json = ?, updated_at = ? WHERE id = ?`, [mergedJson, now, submissionId], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (dbErr) { console.error('Failed to update finding_ids_json:', dbErr); } // 6. Mark queue items complete let queueItemsUpdated = 0; try { const queuePlaceholders = queueItemIds.map(() => '?').join(','); queueItemsUpdated = await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`, [...queueItemIds, req.user.id], function (err) { if (err) reject(err); else resolve(this.changes); } ); }); } catch (queueErr) { console.error('Failed to update queue items:', queueErr); } // 7. Insert history row const historyEntry = buildSubmissionHistoryEntry('findings_added', { addedFindingIds: findingIds, queueItemIds: queueItemIds }, req.user.id, req.user.username); try { await new Promise((resolve, reject) => { db.run( `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (histErr) { console.error('Failed to insert history row:', histErr); } // 8. Audit log logAudit(db, { 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: findingIds, 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-submissions/:id/attachments * * Uploads additional file attachments to an existing FP workflow batch * via the Ivanti attach endpoint. Updates the local submission record's * attachment_count and attachment_results_json, and records the change * in submission history and audit log. * * Content-Type: multipart/form-data * * @param {string} req.params.id - Local FP submission ID * @param {File[]} req.files - One or more file attachments (field name "attachments", * max 10 files, max 10 MB each); allowed extensions: * .pdf .png .jpg .jpeg .gif .doc .docx .xlsx .csv .txt .zip * * @returns {object} 200 - Success (or partial success) * { success: true, * attachmentResults: Array<{ filename: string, success: boolean, error?: string }>, * status: 'success' | 'partial' } * @returns {object} 400 - Validation error, lifecycle guard, file constraint, or UUID resolution failure * { error: string } or { success: false, error: string } * @returns {object} 403 - Ownership violation * { error: string } * @returns {object} 404 - Submission not found * { error: string } * @returns {object} 500 - Server error * { success: false, error: string } */ router.post('/submissions/:id/attachments', requireAuth(db), 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 || []; if (files.length === 0) { return res.status(400).json({ error: 'At least one file is required.' }); } // Validate extensions (belt-and-suspenders) for (const file of files) { if (!isAllowedFileExtension(file.originalname)) { return res.status(400).json({ error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` }); } } (async () => { const submissionId = req.params.id; // 1. Fetch submission and verify ownership const submission = await new Promise((resolve, reject) => { db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { if (err) reject(err); else resolve(row); }); }); 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. Upload each file 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 attachUuid = await resolveWorkflowBatchUuid(db, submission, apiKey, clientId, skipTls); if (!attachUuid) { return res.status(400).json({ success: false, error: 'Could not resolve workflow batch UUID. The workflow may not exist in Ivanti.' }); } const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(attachUuid)}/attach`; const attachmentResults = []; for (const f of files) { try { const result = await ivantiMultipartPost(attachUrl, f.buffer, f.originalname, apiKey, skipTls); const success = result.status === 200 || result.status === 201 || result.status === 202; attachmentResults.push({ filename: f.originalname, success, ...(success ? {} : { error: `Upload failed: ${result.status}` }) }); } catch (uploadErr) { attachmentResults.push({ filename: f.originalname, success: false, error: uploadErr.message }); } } // 4. 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; const now = new Date().toISOString(); try { await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_fp_submissions SET attachment_count = ?, attachment_results_json = ?, updated_at = ? WHERE id = ?`, [newAttachmentCount, JSON.stringify(allResults), now, submissionId], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (dbErr) { console.error('Failed to update attachment records:', dbErr); } // 5. Insert history row const historyEntry = buildSubmissionHistoryEntry('attachments_added', { files: attachmentResults }, req.user.id, req.user.username); try { await new Promise((resolve, reject) => { db.run( `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (histErr) { console.error('Failed to insert history row:', histErr); } // 6. Audit log logAudit(db, { 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 }, 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-submissions/:id/status * * Updates the lifecycle status of an FP submission. Validates the * transition (no transitions allowed from "approved"), records the * change in submission history and audit log. * * @param {string} req.params.id - Local FP submission ID * @param {string} req.body.lifecycle_status - New lifecycle status; one of: * "submitted", "approved", "rejected", * "rework", "resubmitted" * * @returns {object} 200 - Success * { success: true, previousStatus: string, newStatus: string } * @returns {object} 400 - Invalid transition or invalid status value * { error: string } * @returns {object} 403 - Ownership violation * { error: string } * @returns {object} 404 - Submission not found * { error: string } * @returns {object} 500 - Server/database error * { success: false, error: string } */ router.patch('/submissions/:id/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; const newStatus = req.body.lifecycle_status; // 1. Fetch submission and verify ownership const submission = await new Promise((resolve, reject) => { db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { if (err) reject(err); else resolve(row); }); }); 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. Validate transition const transition = validateLifecycleTransition(submission.lifecycle_status, newStatus); if (!transition.valid) { return res.status(400).json({ error: transition.error }); } // 3. Update lifecycle_status and updated_at const now = new Date().toISOString(); const previousStatus = submission.lifecycle_status; try { await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_fp_submissions SET lifecycle_status = ?, updated_at = ? WHERE id = ?`, [newStatus, now, submissionId], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (dbErr) { console.error('Failed to update lifecycle status:', dbErr); return res.status(500).json({ success: false, error: 'Failed to update status.' }); } // 4. Insert history row const historyEntry = buildSubmissionHistoryEntry('status_changed', { from: previousStatus, to: newStatus }, req.user.id, req.user.username); try { await new Promise((resolve, reject) => { db.run( `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], (err) => { if (err) reject(err); else resolve(); } ); }); } catch (histErr) { console.error('Failed to insert history row:', histErr); } // 5. Audit log logAudit(db, { 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;