Add re-queue findings from rejected FP submissions

New feature: users can re-queue findings from a rejected FP submission
back into the Ivanti todo queue under a different workflow type (FP,
Archer, CARD, GRANITE, or DECOM). Primary use case is when an FP is
rejected with a recommendation to submit an Archer risk acceptance.

Backend:
- New migration: add requeued_at column to ivanti_fp_submissions
- New endpoint: POST /api/ivanti/fp-workflow/submissions/:id/requeue
  - Validates workflow_type and vendor (required for FP/Archer/DECOM)
  - Creates new pending queue items from original finding data
  - Marks submission as requeued (prevents double re-queue)
  - Audit logs the action

Frontend (ReportingPage.js):
- RequeueConfirmDialog component with workflow type selector and vendor input
- Re-queue Findings button in Edit FP Modal header (rejected submissions only)
- Already re-queued label when submission.requeued_at is set
- Success notification on completion
This commit is contained in:
Jordan Ramos
2026-05-13 16:46:49 -06:00
parent 828e7cc45d
commit 0fefd2a707
4 changed files with 635 additions and 9 deletions

View File

@@ -137,7 +137,24 @@ const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024
function createIvantiFpWorkflowRouter() {
const router = express.Router();
// GET /documents/search
/**
* 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 {
@@ -160,7 +177,36 @@ function createIvantiFpWorkflowRouter() {
}
});
// POST / — Create FP workflow
/**
* 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) {
@@ -272,7 +318,38 @@ function createIvantiFpWorkflowRouter() {
});
});
// GET /submissions
/**
* 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]);
@@ -324,7 +401,31 @@ function createIvantiFpWorkflowRouter() {
} catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); }
});
// PUT /submissions/:id — Edit FP workflow fields
/**
* 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;
@@ -459,7 +560,30 @@ function createIvantiFpWorkflowRouter() {
});
});
// POST /submissions/:id/findings — Map additional findings to existing workflow
/**
* 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;
@@ -616,7 +740,27 @@ function createIvantiFpWorkflowRouter() {
});
});
// POST /submissions/:id/attachments — Upload additional attachments
/**
* 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) {
@@ -747,7 +891,21 @@ function createIvantiFpWorkflowRouter() {
});
});
// PATCH /submissions/:id/dismiss — Dismiss a rejected submission
/**
* 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;
@@ -786,7 +944,145 @@ function createIvantiFpWorkflowRouter() {
});
});
// PATCH /submissions/:id/status — Update lifecycle status
/**
* 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) {
return res.status(400).json({ error: 'Could not parse queue_item_ids_json.' });
}
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) {
return res.status(400).json({ error: 'No queue items associated with this submission.' });
}
// Fetch original queue items to get finding data
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]
);
if (originalItems.length === 0) {
return res.status(400).json({ error: 'No original queue items found for this submission.' });
}
// INSERT new pending queue items for each finding
const newItems = [];
for (const item of originalItems) {
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 finding_ids = originalItems.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 },
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;