1221 lines
58 KiB
JavaScript
1221 lines
58 KiB
JavaScript
// routes/ivantiFpWorkflow.js
|
|
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const { requireGroup } = require('../middleware/auth');
|
|
const { ivantiFormPost, 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;
|
|
}
|
|
|
|
/**
|
|
* Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint.
|
|
* Format: { subject, filterRequest: { filters } }
|
|
*/
|
|
function buildSubjectFilterRequest(findingIds) {
|
|
return JSON.stringify({
|
|
subject: 'hostFinding',
|
|
filterRequest: {
|
|
filters: [{
|
|
field: 'id',
|
|
exclusive: false,
|
|
operator: 'IN',
|
|
value: findingIds.map(id => String(id)).join(',')
|
|
}]
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds the multipart form fields array for the Ivanti FP workflow request.
|
|
*/
|
|
function buildIvantiFormFields(formData, findingIds) {
|
|
const scopeMap = {
|
|
'Authorized': 'AUTHORIZED',
|
|
'None': 'NONE',
|
|
'Automated': 'AUTOMATED'
|
|
};
|
|
|
|
return [
|
|
{ name: 'name', value: formData.name },
|
|
{ name: 'reason', value: formData.reason },
|
|
{ name: 'description', value: formData.description || '' },
|
|
{ name: 'expirationDate', value: formData.expirationDate },
|
|
{ name: 'overrideControl', value: scopeMap[formData.scopeOverride] || 'AUTHORIZED' },
|
|
{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) },
|
|
{ name: 'isEmptyWorkflow', value: findingIds.length === 0 ? 'true' : 'false' }
|
|
];
|
|
}
|
|
|
|
const LIFECYCLE_STATUSES = new Set([
|
|
'submitted', 'approved', 'rejected', 'rework', 'resubmitted'
|
|
]);
|
|
|
|
/**
|
|
* Validates whether a lifecycle status transition is allowed.
|
|
* Returns { valid: true } or { valid: false, error: string }.
|
|
*/
|
|
function validateLifecycleTransition(currentStatus, newStatus) {
|
|
if (currentStatus === 'approved') {
|
|
return { valid: false, error: 'This submission is finalized and cannot be edited.' };
|
|
}
|
|
if (!LIFECYCLE_STATUSES.has(newStatus)) {
|
|
return { valid: false, error: 'Invalid lifecycle status.' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Merges existing finding IDs (JSON string) with new IDs (array), deduplicates,
|
|
* and returns the merged array as a JSON string.
|
|
*/
|
|
function mergeFindings(existingJson, newIds) {
|
|
const existing = JSON.parse(existingJson || '[]');
|
|
const merged = [...new Set([...existing, ...newIds])];
|
|
return JSON.stringify(merged);
|
|
}
|
|
|
|
/**
|
|
* Builds a submission history entry object.
|
|
* The caller is responsible for setting submission_id on the returned object.
|
|
*/
|
|
function buildSubmissionHistoryEntry(changeType, details, userId, username) {
|
|
return {
|
|
user_id: userId,
|
|
username: username,
|
|
change_type: changeType,
|
|
change_details_json: JSON.stringify(details),
|
|
created_at: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 batchId = String(submission.ivanti_workflow_batch_id);
|
|
|
|
// Try searching by 'id' field with 'internal' projection (matches existing sync pattern)
|
|
const searchBody = {
|
|
filters: [{ field: 'id', exclusive: false, operator: 'EXACT', value: batchId }],
|
|
projection: 'internal',
|
|
sort: [{ field: 'id', direction: 'ASC' }],
|
|
page: 0,
|
|
size: 1
|
|
};
|
|
|
|
let result;
|
|
try {
|
|
result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls);
|
|
} catch (e) {
|
|
console.error('[resolveUUID] Search request failed:', e.message);
|
|
return null;
|
|
}
|
|
|
|
console.log('[resolveUUID] Search status:', result.status, 'for batch ID:', batchId);
|
|
console.log('[resolveUUID] Response body (first 1000 chars):', (result.body || '').substring(0, 1000));
|
|
|
|
if (result.status !== 200) {
|
|
return null;
|
|
}
|
|
|
|
let uuid = null;
|
|
try {
|
|
const data = JSON.parse(result.body);
|
|
// Spring Data REST format
|
|
let batches = [];
|
|
if (data._embedded?.workflowBatches) batches = data._embedded.workflowBatches;
|
|
else if (data._embedded?.workflowBatch) batches = data._embedded.workflowBatch;
|
|
else if (data.content) batches = data.content;
|
|
else if (data.data) batches = data.data;
|
|
else if (Array.isArray(data)) batches = data;
|
|
|
|
console.log('[resolveUUID] Found', batches.length, 'batches');
|
|
|
|
const batch = batches[0];
|
|
if (batch) {
|
|
// Log all keys so we can identify the right field
|
|
console.log('[resolveUUID] Batch keys:', Object.keys(batch).join(', '));
|
|
console.log('[resolveUUID] Batch sample:', JSON.stringify(batch).substring(0, 800));
|
|
// Try common UUID field names
|
|
uuid = batch.uuid || batch.workflowBatchUuid || batch.batchUuid || batch.groupUuid || null;
|
|
} else {
|
|
console.log('[resolveUUID] No batches found for ID:', batchId);
|
|
// Log the full response structure to understand the format
|
|
console.log('[resolveUUID] Response keys:', Object.keys(data).join(', '));
|
|
}
|
|
} catch (e) {
|
|
console.error('[resolveUUID] Failed to parse search response:', e.message);
|
|
return null;
|
|
}
|
|
|
|
// Cache the UUID locally for future use
|
|
if (uuid && submission.id) {
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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
|
|
*
|
|
* Creates a False Positive workflow batch in the Ivanti/RiskSense API,
|
|
* optionally uploads file attachments, records the submission locally,
|
|
* and marks the associated queue items as complete.
|
|
*
|
|
* Content-Type: multipart/form-data
|
|
*
|
|
* @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) or "None"
|
|
* @param {string} req.body.findingIds - JSON-encoded array of Ivanti finding IDs
|
|
* @param {string} req.body.queueItemIds - JSON-encoded array of local queue item IDs
|
|
* @param {File[]} [req.files] - Up to 10 file attachments (max 10 MB each);
|
|
* allowed extensions: .pdf .png .jpg .jpeg .gif
|
|
* .doc .docx .xlsx .csv .txt .zip
|
|
*
|
|
* @returns {object} 200 - Success
|
|
* { success: true, workflowBatchId: number, generatedId: string,
|
|
* attachmentResults: Array<{ filename: string, success: boolean, error?: string }>,
|
|
* queueItemsUpdated: number, status: 'success' | 'partial' }
|
|
* @returns {object} 400 - Validation error
|
|
* { error: string } or { success: false, errors: { [field]: string } }
|
|
* @returns {object} 403 - Queue item ownership violation
|
|
* { error: string }
|
|
* @returns {object} 429 - Ivanti rate limit
|
|
* { success: false, error: string, step: 'create_workflow' }
|
|
* @returns {object} 500 - Server configuration error
|
|
* { success: false, error: string, step: 'create_workflow' }
|
|
* @returns {object} 502 - Ivanti API error
|
|
* { success: false, error: string, step: 'create_workflow', details?: string }
|
|
*/
|
|
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 form fields and call Ivanti API (multipart/form-data)
|
|
const formFields = buildIvantiFormFields(req.body, findingIds);
|
|
const formFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
|
|
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
|
|
|
|
let createResult;
|
|
try {
|
|
createResult = await ivantiFormPost(createUrl, formFields, formFiles, 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 && createResult.status !== 202) {
|
|
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 — API returns { id, created }
|
|
let workflowBatchId;
|
|
try {
|
|
const createData = JSON.parse(createResult.body);
|
|
workflowBatchId = createData.id;
|
|
} 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. 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_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,
|
|
req.body.description || null,
|
|
req.body.expirationDate,
|
|
req.body.scopeOverride || 'Authorized',
|
|
JSON.stringify(findingIds),
|
|
JSON.stringify(queueItemIds),
|
|
files.length,
|
|
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
|
|
status,
|
|
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
|
|
}
|
|
|
|
// 5. Log audit entry
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
|
entityId: String(workflowBatchId),
|
|
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
// 6. 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
|
|
}
|
|
|
|
// 7. Return response
|
|
res.json({
|
|
success: true,
|
|
workflowBatchId,
|
|
queueItemsUpdated,
|
|
status
|
|
});
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in FP workflow submission:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* GET /api/ivanti/fp-submissions
|
|
*
|
|
* 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) => {
|
|
(async () => {
|
|
try {
|
|
const submissions = await new Promise((resolve, reject) => {
|
|
db.all(
|
|
`SELECT * FROM ivanti_fp_submissions WHERE user_id = ? ORDER BY created_at DESC`,
|
|
[req.user.id],
|
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
|
);
|
|
});
|
|
|
|
// Fetch history for all submissions in one query if there are any
|
|
if (submissions.length > 0) {
|
|
const submissionIds = submissions.map(s => s.id);
|
|
const placeholders = submissionIds.map(() => '?').join(',');
|
|
const historyRows = await new Promise((resolve, reject) => {
|
|
db.all(
|
|
`SELECT * FROM ivanti_fp_submission_history WHERE submission_id IN (${placeholders}) ORDER BY created_at ASC`,
|
|
submissionIds,
|
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
|
);
|
|
});
|
|
|
|
// Group history by submission_id
|
|
const historyMap = {};
|
|
for (const row of historyRows) {
|
|
if (!historyMap[row.submission_id]) historyMap[row.submission_id] = [];
|
|
historyMap[row.submission_id].push(row);
|
|
}
|
|
|
|
for (const sub of submissions) {
|
|
sub.history = historyMap[sub.id] || [];
|
|
}
|
|
} else {
|
|
// No submissions, nothing to do
|
|
}
|
|
|
|
res.json(submissions);
|
|
} catch (err) {
|
|
console.error('Error fetching FP submissions:', err);
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in GET /submissions:', unexpectedErr);
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 1. Fetch submission and verify ownership
|
|
const submission = await new Promise((resolve, reject) => {
|
|
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
|
|
if (err) reject(err); else resolve(row);
|
|
});
|
|
});
|
|
|
|
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 edit your own submissions.' });
|
|
}
|
|
|
|
// 2. Lifecycle guard
|
|
if (submission.lifecycle_status === 'approved') {
|
|
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
|
}
|
|
|
|
// 3. Validate body
|
|
const validationErrors = validateFpWorkflowForm(req.body);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
return res.status(400).json({ success: false, errors: validationErrors });
|
|
}
|
|
|
|
// 4. Proxy to Ivanti
|
|
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.' });
|
|
}
|
|
|
|
const scopeMap = { 'Authorized': 'AUTHORIZED', 'None': 'NONE', 'Automated': 'AUTOMATED' };
|
|
const updateUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/update`;
|
|
const updateBody = {
|
|
id: submission.ivanti_workflow_batch_id,
|
|
name: req.body.name,
|
|
reason: req.body.reason,
|
|
description: req.body.description || '',
|
|
expirationDate: req.body.expirationDate,
|
|
overrideControl: scopeMap[req.body.scopeOverride] || 'AUTHORIZED'
|
|
};
|
|
|
|
let ivantiResult;
|
|
try {
|
|
ivantiResult = await ivantiPost(updateUrl, updateBody, apiKey, skipTls);
|
|
} catch (networkErr) {
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_submission_edit_failed', entityType: 'ivanti_workflow',
|
|
details: { error: networkErr.message, submissionId },
|
|
ipAddress: req.ip
|
|
});
|
|
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message });
|
|
}
|
|
|
|
if (ivantiResult.status !== 200 && ivantiResult.status !== 201 && ivantiResult.status !== 202) {
|
|
const errorMap = {
|
|
401: 'Ivanti API key is invalid or missing. Contact your administrator.',
|
|
419: 'API key lacks permissions for this operation.',
|
|
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
|
|
};
|
|
const errorMsg = ivantiResult.status >= 500
|
|
? 'Ivanti API is temporarily unavailable. Please try again later.'
|
|
: (errorMap[ivantiResult.status] || `Operation failed: ${ivantiResult.status}`);
|
|
return res.status(ivantiResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg });
|
|
}
|
|
|
|
// 5. Determine new lifecycle_status
|
|
let newLifecycleStatus = submission.lifecycle_status;
|
|
if (submission.lifecycle_status === 'rejected' || submission.lifecycle_status === 'rework') {
|
|
newLifecycleStatus = 'resubmitted';
|
|
}
|
|
|
|
// 6. Update local record
|
|
const now = new Date().toISOString();
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`UPDATE ivanti_fp_submissions
|
|
SET workflow_name = ?, reason = ?, description = ?, expiration_date = ?, scope_override = ?, lifecycle_status = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[
|
|
req.body.name,
|
|
req.body.reason,
|
|
req.body.description || null,
|
|
req.body.expirationDate,
|
|
req.body.scopeOverride || 'Authorized',
|
|
newLifecycleStatus,
|
|
now,
|
|
submissionId
|
|
],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (dbErr) {
|
|
console.error('Failed to update submission record:', dbErr);
|
|
return res.status(500).json({ success: false, error: 'Failed to update local record.' });
|
|
}
|
|
|
|
// 7. Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('fields_updated', {
|
|
changed: {
|
|
name: { from: submission.workflow_name, to: req.body.name },
|
|
reason: { from: submission.reason, to: req.body.reason },
|
|
description: { from: submission.description, to: req.body.description || '' },
|
|
expirationDate: { from: submission.expiration_date, to: req.body.expirationDate },
|
|
scopeOverride: { from: submission.scope_override, to: req.body.scopeOverride || 'Authorized' }
|
|
}
|
|
}, req.user.id, req.user.username);
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (histErr) {
|
|
console.error('Failed to insert history row:', histErr);
|
|
}
|
|
|
|
// 8. Audit log
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_submission_edited', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, workflowName: req.body.name },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
// 9. Return updated record
|
|
const updatedRecord = await new Promise((resolve, reject) => {
|
|
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
|
|
if (err) reject(err); else resolve(row);
|
|
});
|
|
});
|
|
|
|
res.json({ success: true, submission: updatedRecord });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in PUT /submissions/:id:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
const { findingIds, queueItemIds } = req.body;
|
|
|
|
// Validate 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.' });
|
|
}
|
|
|
|
// 1. Fetch submission and verify ownership
|
|
const submission = await new Promise((resolve, reject) => {
|
|
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
|
|
if (err) reject(err); else resolve(row);
|
|
});
|
|
});
|
|
|
|
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 edit your own submissions.' });
|
|
}
|
|
|
|
// 2. Lifecycle guard
|
|
if (submission.lifecycle_status === 'approved') {
|
|
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
|
}
|
|
|
|
// 3. Verify queue items belong to user, are FP type, and pending
|
|
const placeholders = queueItemIds.map(() => '?').join(',');
|
|
const queueRows = await new Promise((resolve, reject) => {
|
|
db.all(
|
|
`SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id IN (${placeholders})`,
|
|
queueItemIds,
|
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
|
);
|
|
});
|
|
|
|
if (queueRows.length !== queueItemIds.length) {
|
|
return res.status(400).json({ error: 'One or more queue items not found.' });
|
|
}
|
|
for (const row of queueRows) {
|
|
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.` });
|
|
}
|
|
}
|
|
|
|
// 4. Proxy to Ivanti map endpoint
|
|
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.' });
|
|
}
|
|
|
|
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;
|
|
try {
|
|
mapResult = await ivantiFormPost(mapUrl, formFields, [], apiKey, skipTls);
|
|
} catch (networkErr) {
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_findings_add_failed', entityType: 'ivanti_workflow',
|
|
details: { error: networkErr.message, submissionId, findingIds },
|
|
ipAddress: req.ip
|
|
});
|
|
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message });
|
|
}
|
|
|
|
if (mapResult.status !== 200 && mapResult.status !== 201 && mapResult.status !== 202) {
|
|
const errorMap = {
|
|
401: 'Ivanti API key is invalid or missing. Contact your administrator.',
|
|
419: 'API key lacks permissions for this operation.',
|
|
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
|
|
};
|
|
const errorMsg = mapResult.status >= 500
|
|
? 'Ivanti API is temporarily unavailable. Please try again later.'
|
|
: (errorMap[mapResult.status] || `Operation failed: ${mapResult.status}`);
|
|
return res.status(mapResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg });
|
|
}
|
|
|
|
// 5. Merge finding IDs
|
|
const mergedJson = mergeFindings(submission.finding_ids_json, findingIds);
|
|
const now = new Date().toISOString();
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`UPDATE ivanti_fp_submissions SET finding_ids_json = ?, updated_at = ? WHERE id = ?`,
|
|
[mergedJson, now, submissionId],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (dbErr) {
|
|
console.error('Failed to update finding_ids_json:', dbErr);
|
|
}
|
|
|
|
// 6. Mark queue items 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);
|
|
}
|
|
|
|
// 7. Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('findings_added', {
|
|
addedFindingIds: findingIds,
|
|
queueItemIds: queueItemIds
|
|
}, req.user.id, req.user.username);
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (histErr) {
|
|
console.error('Failed to insert history row:', histErr);
|
|
}
|
|
|
|
// 8. Audit log
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_findings_added', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, addedFindingIds: findingIds, queueItemsUpdated },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ success: true, addedFindings: findingIds, queueItemsUpdated });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in POST /submissions/:id/findings:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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) {
|
|
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 });
|
|
}
|
|
|
|
const files = req.files || [];
|
|
if (files.length === 0) {
|
|
return res.status(400).json({ error: 'At least one file is required.' });
|
|
}
|
|
|
|
// Validate extensions (belt-and-suspenders)
|
|
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(', ')}`
|
|
});
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
const submissionId = req.params.id;
|
|
|
|
// 1. Fetch submission and verify ownership
|
|
const submission = await new Promise((resolve, reject) => {
|
|
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
|
|
if (err) reject(err); else resolve(row);
|
|
});
|
|
});
|
|
|
|
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 edit your own submissions.' });
|
|
}
|
|
|
|
// 2. Lifecycle guard
|
|
if (submission.lifecycle_status === 'approved') {
|
|
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
|
}
|
|
|
|
// 3. Upload each file to Ivanti
|
|
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.' });
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
const result = await ivantiMultipartPost(attachUrl, f.buffer, f.originalname, apiKey, skipTls);
|
|
const success = result.status === 200 || result.status === 201 || result.status === 202;
|
|
attachmentResults.push({ filename: f.originalname, success, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
|
|
} catch (uploadErr) {
|
|
attachmentResults.push({ filename: f.originalname, success: false, error: uploadErr.message });
|
|
}
|
|
}
|
|
|
|
// 4. Update attachment_count and attachment_results_json
|
|
const existingResults = JSON.parse(submission.attachment_results_json || '[]');
|
|
const allResults = [...existingResults, ...attachmentResults];
|
|
const successCount = attachmentResults.filter(r => r.success).length;
|
|
const newAttachmentCount = (submission.attachment_count || 0) + successCount;
|
|
const now = new Date().toISOString();
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`UPDATE ivanti_fp_submissions SET attachment_count = ?, attachment_results_json = ?, updated_at = ? WHERE id = ?`,
|
|
[newAttachmentCount, JSON.stringify(allResults), now, submissionId],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (dbErr) {
|
|
console.error('Failed to update attachment records:', dbErr);
|
|
}
|
|
|
|
// 5. Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('attachments_added', {
|
|
files: attachmentResults
|
|
}, req.user.id, req.user.username);
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (histErr) {
|
|
console.error('Failed to insert history row:', histErr);
|
|
}
|
|
|
|
// 6. Audit log
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, attachmentResults },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
const allSucceeded = attachmentResults.every(r => r.success);
|
|
res.json({ success: true, attachmentResults, status: allSucceeded ? 'success' : 'partial' });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in POST /submissions/:id/attachments:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
const newStatus = req.body.lifecycle_status;
|
|
|
|
// 1. Fetch submission and verify ownership
|
|
const submission = await new Promise((resolve, reject) => {
|
|
db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => {
|
|
if (err) reject(err); else resolve(row);
|
|
});
|
|
});
|
|
|
|
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 edit your own submissions.' });
|
|
}
|
|
|
|
// 2. Validate transition
|
|
const transition = validateLifecycleTransition(submission.lifecycle_status, newStatus);
|
|
if (!transition.valid) {
|
|
return res.status(400).json({ error: transition.error });
|
|
}
|
|
|
|
// 3. Update lifecycle_status and updated_at
|
|
const now = new Date().toISOString();
|
|
const previousStatus = submission.lifecycle_status;
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`UPDATE ivanti_fp_submissions SET lifecycle_status = ?, updated_at = ? WHERE id = ?`,
|
|
[newStatus, now, submissionId],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (dbErr) {
|
|
console.error('Failed to update lifecycle status:', dbErr);
|
|
return res.status(500).json({ success: false, error: 'Failed to update status.' });
|
|
}
|
|
|
|
// 4. Insert history row
|
|
const historyEntry = buildSubmissionHistoryEntry('status_changed', {
|
|
from: previousStatus,
|
|
to: newStatus
|
|
}, req.user.id, req.user.username);
|
|
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
`INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at],
|
|
(err) => { if (err) reject(err); else resolve(); }
|
|
);
|
|
});
|
|
} catch (histErr) {
|
|
console.error('Failed to insert history row:', histErr);
|
|
}
|
|
|
|
// 5. Audit log
|
|
logAudit(db, {
|
|
userId: req.user.id, username: req.user.username,
|
|
action: 'ivanti_fp_status_changed', entityType: 'ivanti_workflow',
|
|
entityId: String(submission.ivanti_workflow_batch_id),
|
|
details: { submissionId, from: previousStatus, to: newStatus },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ success: true, previousStatus, newStatus });
|
|
})().catch((unexpectedErr) => {
|
|
console.error('Unexpected error in PATCH /submissions/:id/status:', unexpectedErr);
|
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
module.exports = createIvantiFpWorkflowRouter;
|
|
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
|
|
module.exports.buildIvantiFormFields = buildIvantiFormFields;
|
|
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
|
|
module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
|
module.exports.validateLifecycleTransition = validateLifecycleTransition;
|
|
module.exports.mergeFindings = mergeFindings;
|
|
module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry;
|