2026-03-10 15:29:33 -06:00
|
|
|
// Ivanti / RiskSense Workflow Routes
|
2026-05-06 11:44:17 -06:00
|
|
|
// Data is cached in PostgreSQL and refreshed on a daily schedule or on-demand.
|
2026-03-10 15:29:33 -06:00
|
|
|
|
|
|
|
|
const express = require('express');
|
2026-05-06 11:44:17 -06:00
|
|
|
const pool = require('../db');
|
|
|
|
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
2026-04-07 16:20:24 -06:00
|
|
|
const { ivantiPost } = require('../helpers/ivantiApi');
|
2026-03-10 15:29:33 -06:00
|
|
|
|
|
|
|
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
// Core sync — calls Ivanti API, stores result in PostgreSQL
|
2026-03-10 15:29:33 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
async function syncWorkflows() {
|
2026-03-10 15:29:33 -06:00
|
|
|
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);
|
2026-05-06 11:44:17 -06:00
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
|
|
|
[errMsg]
|
|
|
|
|
);
|
2026-03-10 15:29:33 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
// 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)...`);
|
2026-03-10 15:29:33 -06:00
|
|
|
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
2026-06-10 11:22:51 -06:00
|
|
|
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;
|
2026-03-10 15:29:33 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
let workflows = [];
|
2026-03-10 15:29:33 -06:00
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
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;
|
|
|
|
|
});
|
2026-03-10 15:29:33 -06:00
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-10 15:29:33 -06:00
|
|
|
}
|
2026-06-10 11:22:51 -06:00
|
|
|
}
|
2026-03-10 15:29:33 -06:00
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
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)]
|
|
|
|
|
);
|
2026-03-10 15:29:33 -06:00
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
console.log(`[Ivanti] Sync complete — ${allWorkflows.length} total workflows`);
|
2026-03-10 15:29:33 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
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();
|
2026-03-10 15:29:33 -06:00
|
|
|
} else {
|
2026-05-06 11:44:17 -06:00
|
|
|
const lastSync = new Date(row.synced_at);
|
2026-03-10 15:29:33 -06:00
|
|
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
|
|
|
|
if (hoursSince >= 24) {
|
2026-05-06 11:44:17 -06:00
|
|
|
syncWorkflows();
|
2026-03-10 15:29:33 -06:00
|
|
|
} else {
|
|
|
|
|
const hoursUntil = (24 - hoursSince).toFixed(1);
|
|
|
|
|
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti] Schedule check failed:', err);
|
|
|
|
|
syncWorkflows();
|
|
|
|
|
}
|
2026-03-10 15:29:33 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
setInterval(() => syncWorkflows(), SYNC_INTERVAL_MS);
|
2026-03-10 15:29:33 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helper — read current state from DB and return as JSON-ready object
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
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 {
|
2026-05-12 14:21:46 -06:00
|
|
|
total: workflows.length,
|
2026-05-06 11:44:17 -06:00
|
|
|
workflows,
|
|
|
|
|
synced_at: row.synced_at,
|
|
|
|
|
sync_status: row.sync_status,
|
|
|
|
|
error_message: row.error_message
|
|
|
|
|
};
|
2026-03-10 15:29:33 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Router
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
function createIvantiWorkflowsRouter() {
|
2026-03-10 15:29:33 -06:00
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Kick off scheduler (fire-and-forget on startup)
|
|
|
|
|
scheduleSync().catch((err) => console.error('[Ivanti] Init failed:', err));
|
2026-03-10 15:29:33 -06:00
|
|
|
|
|
|
|
|
// All routes require authentication
|
2026-05-06 11:44:17 -06:00
|
|
|
router.use(requireAuth());
|
2026-03-10 15:29:33 -06:00
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
/**
|
|
|
|
|
* 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' }
|
|
|
|
|
*/
|
2026-03-10 15:29:33 -06:00
|
|
|
router.get('/', async (req, res) => {
|
|
|
|
|
try {
|
2026-06-10 11:22:51 -06:00
|
|
|
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);
|
2026-03-10 15:29:33 -06:00
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Database error reading sync state' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-10 11:22:51 -06:00
|
|
|
/**
|
|
|
|
|
* 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' }
|
|
|
|
|
*/
|
2026-04-07 09:52:26 -06:00
|
|
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-06 11:44:17 -06:00
|
|
|
await syncWorkflows();
|
2026-03-10 15:29:33 -06:00
|
|
|
try {
|
2026-05-06 11:44:17 -06:00
|
|
|
res.json(await readState());
|
2026-03-10 15:29:33 -06:00
|
|
|
} catch {
|
|
|
|
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = createIvantiWorkflowsRouter;
|