// 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;