From 2b6db1f903441d38527caf9049cd6b39653fdfc9 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 13 Apr 2026 12:53:13 -0600 Subject: [PATCH] fix: resolve UUID for map/attach endpoints, fix attachment field name mismatch - Add resolveWorkflowBatchUuid helper that searches Ivanti API for UUID by batch ID and caches it locally - Use UUID resolver in findings and attachments endpoints instead of relying on stored UUID - Store UUID on new FP creation by searching Ivanti after workflow batch is created - Fix frontend attachment upload field name from 'files' to 'attachments' to match Multer config --- backend/routes/ivantiFpWorkflow.js | 210 +++++++++++++++++++++++++---- 1 file changed, 186 insertions(+), 24 deletions(-) diff --git a/backend/routes/ivantiFpWorkflow.js b/backend/routes/ivantiFpWorkflow.js index 9d25a8c..9535be7 100644 --- a/backend/routes/ivantiFpWorkflow.js +++ b/backend/routes/ivantiFpWorkflow.js @@ -154,6 +154,45 @@ function buildSubmissionHistoryEntry(changeType, details, userId, username) { }; } +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- @@ -355,16 +394,25 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { // 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_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `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, @@ -427,10 +475,21 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - // ----------------------------------------------------------------------- - // GET /api/ivanti/fp-submissions - // Returns the authenticated user's FP submission records with history. - // ----------------------------------------------------------------------- + /** + * 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 { @@ -479,10 +538,37 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - // ----------------------------------------------------------------------- - // PUT /api/ivanti/fp-submissions/:id - // Updates form fields and proxies to Ivanti update endpoint. - // ----------------------------------------------------------------------- + /** + * 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; @@ -636,10 +722,33 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - // ----------------------------------------------------------------------- - // POST /api/ivanti/fp-submissions/:id/findings - // Maps additional findings to the existing workflow batch. - // ----------------------------------------------------------------------- + /** + * 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; @@ -706,7 +815,12 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); } - const mapUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(submission.ivanti_workflow_batch_uuid)}/map`; + 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; @@ -800,10 +914,34 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - // ----------------------------------------------------------------------- - // POST /api/ivanti/fp-submissions/:id/attachments - // Uploads additional files to the existing workflow batch. - // ----------------------------------------------------------------------- + /** + * 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) { @@ -858,7 +996,12 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); } - const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(submission.ivanti_workflow_batch_uuid)}/attach`; + 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) { @@ -926,10 +1069,29 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - // ----------------------------------------------------------------------- - // PATCH /api/ivanti/fp-submissions/:id/status - // Updates the lifecycle status of a submission. - // ----------------------------------------------------------------------- + /** + * 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;