Each user can now have ivanti_first_name and ivanti_last_name configured in User Management. The workflow sync queries all configured Ivanti identities and fetches workflows for each. The GET endpoint filters workflows to only show those belonging to the logged-in user's Ivanti identity. Users without an Ivanti identity see all workflows (admin fallback). If no users have identities configured, falls back to IVANTI_FIRST_NAME/ IVANTI_LAST_NAME from .env for backward compatibility. Changes: - Migration adds ivanti_first_name, ivanti_last_name to users table - Users route accepts and returns the new fields - User Management UI has Ivanti Identity input fields - Workflow sync iterates all configured user identities - Workflow GET filters by logged-in user's identity
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
// Ivanti / RiskSense Workflow Routes
|
|
// Data is cached in PostgreSQL and refreshed on a daily schedule or on-demand.
|
|
|
|
const express = require('express');
|
|
const pool = require('../db');
|
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
|
const { ivantiPost } = require('../helpers/ivantiApi');
|
|
|
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core sync — calls Ivanti API, stores result in PostgreSQL
|
|
// ---------------------------------------------------------------------------
|
|
async function syncWorkflows() {
|
|
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) {
|
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
|
console.warn('[Ivanti]', errMsg);
|
|
await pool.query(
|
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
[errMsg]
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get all unique Ivanti identities from users table
|
|
const { rows: ivantiUsers } = await pool.query(
|
|
`SELECT DISTINCT ivanti_first_name, ivanti_last_name
|
|
FROM users
|
|
WHERE ivanti_first_name IS NOT NULL AND ivanti_last_name IS NOT NULL
|
|
AND ivanti_first_name != '' AND ivanti_last_name != ''`
|
|
);
|
|
|
|
// Fallback to env var if no users have Ivanti identity configured
|
|
if (ivantiUsers.length === 0) {
|
|
const envFirst = process.env.IVANTI_FIRST_NAME || '';
|
|
const envLast = process.env.IVANTI_LAST_NAME || '';
|
|
if (envFirst && envLast) {
|
|
ivantiUsers.push({ ivanti_first_name: envFirst, ivanti_last_name: envLast });
|
|
} else {
|
|
const errMsg = 'No Ivanti identities configured — set ivanti_first_name/ivanti_last_name on user accounts';
|
|
console.warn('[Ivanti]', errMsg);
|
|
await pool.query(
|
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
[errMsg]
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
console.log(`[Ivanti] Syncing workflows for ${ivantiUsers.length} user(s)...`);
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
|
let allWorkflows = [];
|
|
|
|
for (const user of ivantiUsers) {
|
|
const body = {
|
|
filters: [
|
|
{
|
|
field: 'created_by_last_name',
|
|
exclusive: false,
|
|
operator: 'IN',
|
|
orWithPrevious: false,
|
|
implicitFilters: [],
|
|
value: user.ivanti_last_name,
|
|
caseSensitive: false
|
|
},
|
|
{
|
|
field: 'created_by_first_name',
|
|
exclusive: false,
|
|
operator: 'IN',
|
|
orWithPrevious: false,
|
|
implicitFilters: [],
|
|
value: user.ivanti_first_name,
|
|
caseSensitive: false
|
|
}
|
|
],
|
|
projection: 'internal',
|
|
sort: [{ field: 'created', direction: 'DESC' }],
|
|
page: 0,
|
|
size: 50
|
|
};
|
|
|
|
try {
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
if (result.status === 401) {
|
|
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
|
}
|
|
if (result.status === 419) {
|
|
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
|
}
|
|
if (result.status === 429) {
|
|
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
|
}
|
|
if (result.status !== 200) {
|
|
console.error(`[Ivanti] Workflow sync for ${user.ivanti_first_name} ${user.ivanti_last_name} returned ${result.status}`);
|
|
continue;
|
|
}
|
|
|
|
const data = JSON.parse(result.body);
|
|
let workflows = [];
|
|
|
|
if (data.page && typeof data.page.totalElements === 'number') {
|
|
workflows = data._embedded?.workflowBatches
|
|
|| data._embedded?.workflowBatch
|
|
|| [];
|
|
} else if (typeof data.total === 'number') {
|
|
workflows = data.data || data.content || data.results || [];
|
|
} else if (typeof data.totalElements === 'number') {
|
|
workflows = data.content || data.data || [];
|
|
} else if (Array.isArray(data)) {
|
|
workflows = data;
|
|
}
|
|
|
|
// Tag each workflow with the Ivanti identity that owns it
|
|
workflows.forEach(w => {
|
|
w._ivanti_first_name = user.ivanti_first_name;
|
|
w._ivanti_last_name = user.ivanti_last_name;
|
|
});
|
|
|
|
allWorkflows = allWorkflows.concat(workflows);
|
|
console.log(`[Ivanti] ${user.ivanti_first_name} ${user.ivanti_last_name}: ${workflows.length} workflows`);
|
|
} catch (err) {
|
|
console.error(`[Ivanti] Workflow sync failed for ${user.ivanti_first_name} ${user.ivanti_last_name}:`, err.message);
|
|
// If it's a fatal error (auth), break and report
|
|
if (err.message.includes('401') || err.message.includes('419')) {
|
|
await pool.query(
|
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
[err.message]
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
await pool.query(
|
|
`UPDATE ivanti_sync_state
|
|
SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
|
|
WHERE id=1`,
|
|
[allWorkflows.length, JSON.stringify(allWorkflows)]
|
|
);
|
|
|
|
console.log(`[Ivanti] Sync complete — ${allWorkflows.length} total workflows`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
|
// ---------------------------------------------------------------------------
|
|
async function scheduleSync() {
|
|
try {
|
|
const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1');
|
|
const row = rows[0];
|
|
if (!row || !row.synced_at) {
|
|
syncWorkflows();
|
|
} else {
|
|
const lastSync = new Date(row.synced_at);
|
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
|
if (hoursSince >= 24) {
|
|
syncWorkflows();
|
|
} else {
|
|
const hoursUntil = (24 - hoursSince).toFixed(1);
|
|
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[Ivanti] Schedule check failed:', err);
|
|
syncWorkflows();
|
|
}
|
|
|
|
setInterval(() => syncWorkflows(), SYNC_INTERVAL_MS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper — read current state from DB and return as JSON-ready object
|
|
// ---------------------------------------------------------------------------
|
|
async function readState() {
|
|
const { rows } = await pool.query(
|
|
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1'
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return { total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null };
|
|
|
|
let workflows = [];
|
|
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
|
|
|
return {
|
|
total: workflows.length,
|
|
workflows,
|
|
synced_at: row.synced_at,
|
|
sync_status: row.sync_status,
|
|
error_message: row.error_message
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Router
|
|
// ---------------------------------------------------------------------------
|
|
function createIvantiWorkflowsRouter() {
|
|
const router = express.Router();
|
|
|
|
// Kick off scheduler (fire-and-forget on startup)
|
|
scheduleSync().catch((err) => console.error('[Ivanti] Init failed:', err));
|
|
|
|
// All routes require authentication
|
|
router.use(requireAuth());
|
|
|
|
/**
|
|
* GET /api/ivanti/workflows
|
|
*
|
|
* Returns cached Ivanti workflow data filtered by the logged-in user's
|
|
* Ivanti identity (ivanti_first_name / ivanti_last_name on their account).
|
|
* If the user has no Ivanti identity configured, returns all workflows (admin view).
|
|
*
|
|
* @returns {object} 200 - { total, workflows, synced_at, sync_status, error_message }
|
|
* @returns {object} 500 - { error: 'Database error reading sync state' }
|
|
*/
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const state = await readState();
|
|
|
|
// Get logged-in user's Ivanti identity
|
|
const { rows: userRows } = await pool.query(
|
|
'SELECT ivanti_first_name, ivanti_last_name FROM users WHERE id = $1',
|
|
[req.user.id]
|
|
);
|
|
const ivantiUser = userRows[0];
|
|
|
|
// If user has Ivanti identity, filter workflows to only theirs
|
|
if (ivantiUser && ivantiUser.ivanti_first_name && ivantiUser.ivanti_last_name) {
|
|
state.workflows = state.workflows.filter(w =>
|
|
(w._ivanti_first_name || '').toLowerCase() === ivantiUser.ivanti_first_name.toLowerCase() &&
|
|
(w._ivanti_last_name || '').toLowerCase() === ivantiUser.ivanti_last_name.toLowerCase()
|
|
);
|
|
state.total = state.workflows.length;
|
|
}
|
|
// If no Ivanti identity configured, show all (admin view)
|
|
|
|
res.json(state);
|
|
} catch {
|
|
res.status(500).json({ error: 'Database error reading sync state' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ivanti/workflows/sync
|
|
*
|
|
* Triggers an immediate Ivanti workflow sync for all configured user identities,
|
|
* awaits completion, and returns the updated cached state. Requires Admin or
|
|
* Standard_User group.
|
|
*
|
|
* @returns {object} 200 - { total, workflows, synced_at, sync_status, error_message }
|
|
* @returns {object} 401 - { error: 'Authentication required' }
|
|
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin', 'Standard_User'], current: '...' }
|
|
* @returns {object} 500 - { error: 'Sync ran but could not read updated state' }
|
|
*/
|
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
await syncWorkflows();
|
|
try {
|
|
res.json(await readState());
|
|
} catch {
|
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
module.exports = createIvantiWorkflowsRouter;
|