From 0f83f48cc6513af78e1c7e76424659c84aa8b78c Mon Sep 17 00:00:00 2001
From: Jordan Ramos
Date: Wed, 10 Jun 2026 11:22:51 -0600
Subject: [PATCH] Per-user Ivanti identity for FP workflow filtering
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
---
.../migrations/add_user_ivanti_identity.js | 31 +++
backend/migrations/run-all.js | 1 +
backend/routes/ivantiWorkflows.js | 235 ++++++++++++------
backend/routes/users.js | 96 ++++++-
frontend/src/components/UserManagement.js | 38 ++-
5 files changed, 308 insertions(+), 93 deletions(-)
create mode 100644 backend/migrations/add_user_ivanti_identity.js
diff --git a/backend/migrations/add_user_ivanti_identity.js b/backend/migrations/add_user_ivanti_identity.js
new file mode 100644
index 0000000..16f8a37
--- /dev/null
+++ b/backend/migrations/add_user_ivanti_identity.js
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+// Migration: Add ivanti_first_name and ivanti_last_name to users table
+// Allows per-user Ivanti identity for workflow filtering.
+
+const pool = require('../db');
+
+async function run() {
+ console.log('Adding Ivanti identity columns to users table...');
+ try {
+ await pool.query(`
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS ivanti_first_name VARCHAR(100) DEFAULT NULL
+ `);
+ console.log('✓ ivanti_first_name column added (or already exists)');
+
+ await pool.query(`
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS ivanti_last_name VARCHAR(100) DEFAULT NULL
+ `);
+ console.log('✓ ivanti_last_name column added (or already exists)');
+
+ console.log('Migration complete.');
+ } catch (err) {
+ console.error('Migration failed:', err.message);
+ process.exit(1);
+ } finally {
+ await pool.end();
+ }
+}
+
+run();
diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js
index 6b5ac5b..5952f7e 100644
--- a/backend/migrations/run-all.js
+++ b/backend/migrations/run-all.js
@@ -31,6 +31,7 @@ const POSTGRES_MIGRATIONS = [
'add_remediate_workflow_type.js',
'add_notifications_table.js',
'add_ivanti_findings_ipv6_columns.js',
+ 'add_user_ivanti_identity.js',
];
async function runAll() {
diff --git a/backend/routes/ivantiWorkflows.js b/backend/routes/ivantiWorkflows.js
index 7d3dbec..8b3759b 100644
--- a/backend/routes/ivantiWorkflows.js
+++ b/backend/routes/ivantiWorkflows.js
@@ -14,8 +14,6 @@ const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
async function syncWorkflows() {
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) {
@@ -28,89 +26,125 @@ async function syncWorkflows() {
return;
}
- console.log('[Ivanti] Syncing workflows...');
+ // 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`;
- 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
+ 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;
}
- ],
- projection: 'internal',
- sort: [{ field: 'created', direction: 'DESC' }],
- page: 0,
- size: 50
- };
- try {
- const result = await ivantiPost(urlPath, body, apiKey, skipTls);
+ const data = JSON.parse(result.body);
+ let workflows = [];
- if (result.status === 401) {
- throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
+ 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;
+ }
}
- 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);
-
- 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 pool.query(
- `UPDATE ivanti_sync_state
- SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
- WHERE id=1`,
- [total, JSON.stringify(workflows)]
- );
-
- console.log(`[Ivanti] Sync complete — ${total} workflows`);
- } catch (err) {
- const msg = err.message || 'Unknown error';
- console.error('[Ivanti] Sync failed:', msg);
- await pool.query(
- `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
- [msg]
- );
}
+
+ 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`);
}
// ---------------------------------------------------------------------------
@@ -174,16 +208,55 @@ function createIvantiWorkflowsRouter() {
// All routes require authentication
router.use(requireAuth());
- // GET / — return cached data (fast, no external call)
+ /**
+ * 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 {
- res.json(await readState());
+ 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 /sync — trigger an immediate sync, await completion, return fresh 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 {
diff --git a/backend/routes/users.js b/backend/routes/users.js
index 64ea2fa..a3662ec 100644
--- a/backend/routes/users.js
+++ b/backend/routes/users.js
@@ -10,11 +10,28 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) {
// All routes require Admin group
router.use(requireAuth(), requireGroup('Admin'));
- // Get all users
+ /**
+ * GET /api/users
+ *
+ * Returns all user accounts ordered by creation date (newest first).
+ *
+ * @returns {Array
+ {/* Ivanti Identity */}
+
+
+
+ setFormData({ ...formData, ivanti_first_name: e.target.value })}
+ placeholder="First name in Ivanti"
+ />
+ setFormData({ ...formData, ivanti_last_name: e.target.value })}
+ placeholder="Last name in Ivanti"
+ />
+
+
+ Used to filter FP workflows — must match the name in Ivanti exactly.
+
+
+