// Ivanti / RiskSense Workflow Routes // Data is cached in SQLite and refreshed on a daily schedule or on-demand. // Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json) // Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited const express = require('express'); const https = require('https'); const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours // --------------------------------------------------------------------------- // HTTP helper — uses Node's https module directly so we can toggle // rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true) // --------------------------------------------------------------------------- function ivantiPost(urlPath, body, apiKey, skipTls) { const bodyStr = JSON.stringify(body); const fullUrl = new URL(IVANTI_URL_BASE + urlPath); return new Promise((resolve, reject) => { const options = { hostname: fullUrl.hostname, path: fullUrl.pathname + fullUrl.search, method: 'POST', headers: { 'accept': '*/*', 'content-type': 'application/json', 'x-api-key': apiKey, 'x-http-client-type': 'browser', 'content-length': Buffer.byteLength(bodyStr) }, rejectUnauthorized: !skipTls, timeout: 15000 }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => resolve({ status: res.statusCode, body: data })); }); req.on('timeout', () => req.destroy(new Error('Request timed out'))); req.on('error', reject); req.write(bodyStr); req.end(); }); } // --------------------------------------------------------------------------- // Ensure the sync state table exists (idempotent — safe to call on every start) // --------------------------------------------------------------------------- function initTable(db) { return new Promise((resolve, reject) => { db.serialize(() => { db.run(` CREATE TABLE IF NOT EXISTS ivanti_sync_state ( id INTEGER PRIMARY KEY CHECK (id = 1), total INTEGER DEFAULT 0, workflows_json TEXT DEFAULT '[]', synced_at DATETIME, sync_status TEXT DEFAULT 'never', error_message TEXT ) `, (err) => { if (err) return reject(err); }); db.run(` INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status) VALUES (1, 0, '[]', 'never') `, (err) => { if (err) reject(err); else resolve(); }); }); }); } // --------------------------------------------------------------------------- // Core sync — calls Ivanti API, stores result in SQLite // --------------------------------------------------------------------------- async function syncWorkflows(db) { const apiKey = process.env.IVANTI_API_KEY; const clientId = process.env.IVANTI_CLIENT_ID || '1550'; const firstName = process.env.IVANTI_FIRST_NAME || ''; const lastName = process.env.IVANTI_LAST_NAME || ''; 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 new Promise((resolve) => { db.run( `UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg], resolve ); }); return; } console.log('[Ivanti] Syncing workflows...'); const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`; const body = { filters: [ { field: 'created_by_last_name', exclusive: false, operator: 'IN', orWithPrevious: false, implicitFilters: [], value: lastName, caseSensitive: false }, { field: 'created_by_first_name', exclusive: false, operator: 'IN', orWithPrevious: false, implicitFilters: [], value: firstName, 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) { throw new Error(`Ivanti API returned unexpected status ${result.status}`); } const data = JSON.parse(result.body); // Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } } let total = 0; let workflows = []; if (data.page && typeof data.page.totalElements === 'number') { total = data.page.totalElements; workflows = data._embedded?.workflowBatches || data._embedded?.workflowBatch || []; } else if (typeof data.total === 'number') { total = data.total; workflows = data.data || data.content || data.results || []; } else if (typeof data.totalElements === 'number') { total = data.totalElements; workflows = data.content || data.data || []; } else if (Array.isArray(data)) { workflows = data; total = data.length; } await new Promise((resolve, reject) => { db.run( `UPDATE ivanti_sync_state SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`, [total, JSON.stringify(workflows)], (err) => { if (err) reject(err); else resolve(); } ); }); console.log(`[Ivanti] Sync complete — ${total} workflows`); } catch (err) { const msg = err.message || 'Unknown error'; console.error('[Ivanti] Sync failed:', msg); await new Promise((resolve) => { db.run( `UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg], resolve ); }); } } // --------------------------------------------------------------------------- // Scheduler — runs sync immediately if >24h stale, then every 24h // --------------------------------------------------------------------------- function scheduleSync(db) { db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => { if (err || !row || !row.synced_at) { syncWorkflows(db); } else { const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z'); const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60); if (hoursSince >= 24) { syncWorkflows(db); } else { const hoursUntil = (24 - hoursSince).toFixed(1); console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`); } } }); setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS); } // --------------------------------------------------------------------------- // Helper — read current state from DB and return as JSON-ready object // --------------------------------------------------------------------------- function readState(db) { return new Promise((resolve, reject) => { db.get( 'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1', (err, row) => { if (err) return reject(err); if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null }); let workflows = []; try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } resolve({ total: row.total || 0, workflows, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message }); } ); }); } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- function createIvantiWorkflowsRouter(db, requireAuth) { const router = express.Router(); // Init table and kick off scheduler (fire-and-forget on startup) initTable(db) .then(() => scheduleSync(db)) .catch((err) => console.error('[Ivanti] Init failed:', err)); // All routes require authentication router.use(requireAuth(db)); // GET / — return cached data (fast, no external call) router.get('/', async (req, res) => { try { res.json(await readState(db)); } catch { res.status(500).json({ error: 'Database error reading sync state' }); } }); // POST /sync — trigger an immediate sync, await completion, return fresh state router.post('/sync', async (req, res) => { await syncWorkflows(db); try { res.json(await readState(db)); } catch { res.status(500).json({ error: 'Sync ran but could not read updated state' }); } }); return router; } module.exports = createIvantiWorkflowsRouter;