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
This commit is contained in:
jramos
2026-04-13 12:53:13 -06:00
parent 7c97bc3a84
commit 2b6db1f903

View File

@@ -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 // Multer configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -355,16 +394,25 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
// 3. Determine submission status (files sent inline, so success if we got here) // 3. Determine submission status (files sent inline, so success if we got here)
const status = 'success'; 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 // 4. Insert submission record
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
db.run( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
req.user.id, req.user.id,
req.user.username, req.user.username,
workflowBatchId, workflowBatchId,
workflowBatchUuid,
null, // generatedId not returned by this endpoint null, // generatedId not returned by this endpoint
req.body.name, req.body.name,
req.body.reason, req.body.reason,
@@ -427,10 +475,21 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}); });
}); });
// ----------------------------------------------------------------------- /**
// GET /api/ivanti/fp-submissions * GET /api/ivanti/fp-submissions
// Returns the authenticated user's FP submission records with history. *
// ----------------------------------------------------------------------- * Returns the authenticated user's FP submission records, each enriched
* with its submission history entries.
*
* @returns {Array<object>} 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<object> }
* @returns {object} 500 - Internal server error
* { error: string }
*/
router.get('/submissions', requireAuth(db), (req, res) => { router.get('/submissions', requireAuth(db), (req, res) => {
(async () => { (async () => {
try { try {
@@ -479,10 +538,37 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}); });
}); });
// ----------------------------------------------------------------------- /**
// PUT /api/ivanti/fp-submissions/:id * PUT /api/ivanti/fp-submissions/:id
// Updates form fields and proxies to Ivanti update endpoint. *
// ----------------------------------------------------------------------- * 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) => { router.put('/submissions/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => { (async () => {
const submissionId = req.params.id; const submissionId = req.params.id;
@@ -636,10 +722,33 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}); });
}); });
// ----------------------------------------------------------------------- /**
// POST /api/ivanti/fp-submissions/:id/findings * POST /api/ivanti/fp-submissions/:id/findings
// Maps additional findings to the existing workflow batch. *
// ----------------------------------------------------------------------- * 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) => { router.post('/submissions/:id/findings', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => { (async () => {
const submissionId = req.params.id; 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.' }); 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) }]; const formFields = [{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) }];
let mapResult; let mapResult;
@@ -800,10 +914,34 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}); });
}); });
// ----------------------------------------------------------------------- /**
// POST /api/ivanti/fp-submissions/:id/attachments * POST /api/ivanti/fp-submissions/:id/attachments
// Uploads additional files to the existing workflow batch. *
// ----------------------------------------------------------------------- * 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) => { router.post('/submissions/:id/attachments', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
fpUpload(req, res, (multerErr) => { fpUpload(req, res, (multerErr) => {
if (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.' }); 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 = []; const attachmentResults = [];
for (const f of files) { for (const f of files) {
@@ -926,10 +1069,29 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}); });
}); });
// ----------------------------------------------------------------------- /**
// PATCH /api/ivanti/fp-submissions/:id/status * PATCH /api/ivanti/fp-submissions/:id/status
// Updates the lifecycle status of a submission. *
// ----------------------------------------------------------------------- * 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) => { router.patch('/submissions/:id/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
(async () => { (async () => {
const submissionId = req.params.id; const submissionId = req.params.id;