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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user