feat: add Ivanti FP workflow submission from Queue
- Add shared ivantiApi.js helper (ivantiPost + ivantiMultipartPost) - Add ivantiFpWorkflow.js backend route with validation, Ivanti API workflow creation, attachment uploads, submission tracking, and audit - Add add_fp_submissions_table.js migration - Wire route into server.js at /api/ivanti/fp-workflow - Add FpWorkflowModal component in ReportingPage.js with form fields, drag-and-drop file upload, progress indicator, and result views - Add Create FP Workflow button to QueuePanel footer (editor/admin only) - Refactor ivantiWorkflows.js and ivantiFindings.js to use shared helper
This commit is contained in:
369
backend/routes/ivantiFpWorkflow.js
Normal file
369
backend/routes/ivantiFpWorkflow.js
Normal file
@@ -0,0 +1,369 @@
|
||||
// routes/ivantiFpWorkflow.js
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const { 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the Ivanti API request body for an FP workflow batch.
|
||||
*/
|
||||
function buildIvantiPayload(formData, findingIds) {
|
||||
const scopeMap = {
|
||||
'Authorized': 'AUTHORIZED',
|
||||
'None': 'NONE'
|
||||
};
|
||||
|
||||
const payload = {
|
||||
type: 'FALSE_POSITIVE',
|
||||
subType: 'FALSE_POSITIVE',
|
||||
name: formData.name,
|
||||
reason: formData.reason,
|
||||
expirationDate: formData.expirationDate,
|
||||
scopeOverrideAuthorization: scopeMap[formData.scopeOverride] || 'AUTHORIZED',
|
||||
hostFindingIds: findingIds.map(id => parseInt(id, 10))
|
||||
};
|
||||
|
||||
if (formData.description && formData.description.trim().length > 0) {
|
||||
payload.description = formData.description;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
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 payload and call Ivanti API to create workflow batch
|
||||
const payload = buildIvantiPayload(req.body, findingIds);
|
||||
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch`;
|
||||
|
||||
let createResult;
|
||||
try {
|
||||
createResult = await ivantiPost(createUrl, payload, 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) {
|
||||
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
|
||||
let workflowBatchId, generatedId;
|
||||
try {
|
||||
const createData = JSON.parse(createResult.body);
|
||||
workflowBatchId = createData.id;
|
||||
generatedId = createData.generatedId;
|
||||
} 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. Upload attachments (if any)
|
||||
const attachmentResults = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/${encodeURIComponent(workflowBatchId)}/attachment`;
|
||||
const attachResult = await ivantiMultipartPost(attachUrl, file.buffer, file.originalname, apiKey, skipTls);
|
||||
if (attachResult.status === 200 || attachResult.status === 201) {
|
||||
attachmentResults.push({ filename: file.originalname, success: true });
|
||||
} else {
|
||||
attachmentResults.push({ filename: file.originalname, success: false, error: `Status ${attachResult.status}` });
|
||||
}
|
||||
} catch (attachErr) {
|
||||
attachmentResults.push({ filename: file.originalname, success: false, error: attachErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Determine submission status
|
||||
const failedAttachments = attachmentResults.filter(r => !r.success);
|
||||
let status = 'success';
|
||||
if (files.length > 0 && failedAttachments.length > 0) {
|
||||
status = failedAttachments.length === files.length ? 'partial' : 'partial';
|
||||
}
|
||||
|
||||
// 5. 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
workflowBatchId,
|
||||
generatedId,
|
||||
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(attachmentResults),
|
||||
status,
|
||||
failedAttachments.length > 0 ? `${failedAttachments.length} attachment(s) failed` : 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
|
||||
}
|
||||
|
||||
// 6. Log audit entry
|
||||
logAudit(db, {
|
||||
userId: req.user.id, username: req.user.username,
|
||||
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
||||
entityId: generatedId,
|
||||
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
// 7. 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
|
||||
}
|
||||
|
||||
// 8. Return response
|
||||
res.json({
|
||||
success: true,
|
||||
workflowBatchId,
|
||||
generatedId,
|
||||
attachmentResults,
|
||||
queueItemsUpdated,
|
||||
status
|
||||
});
|
||||
})().catch((unexpectedErr) => {
|
||||
console.error('Unexpected error in FP workflow submission:', unexpectedErr);
|
||||
res.status(500).json({ success: false, error: 'Internal server error.' });
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createIvantiFpWorkflowRouter;
|
||||
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
|
||||
module.exports.buildIvantiPayload = buildIvantiPayload;
|
||||
module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
||||
Reference in New Issue
Block a user