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} 200 - Array of user objects + * @returns {Object} 200[].id - User ID + * @returns {string} 200[].username - Username + * @returns {string} 200[].email - Email address + * @returns {string} 200[].group - Permission group (Admin, Standard_User, Leadership, Read_Only) + * @returns {string} 200[].bu_teams - Comma-separated BU team assignments + * @returns {Array} 200[].teams - Parsed array of BU team assignments + * @returns {boolean} 200[].is_active - Whether the account is active + * @returns {string} 200[].created_at - ISO timestamp of account creation + * @returns {string|null} 200[].last_login - ISO timestamp of last login + * @returns {Object} 500 - { error: string } + */ router.get('/', async (req, res) => { try { const { rows: users } = await pool.query( - `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login + `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login, + ivanti_first_name, ivanti_last_name FROM users ORDER BY created_at DESC` ); // Parse bu_teams into teams array for each user @@ -29,11 +46,21 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) { } }); - // Get single user + /** + * GET /api/users/:id + * + * Returns a single user account by ID. + * + * @param {string} req.params.id - User ID + * @returns {Object} 200 - User object with parsed teams array + * @returns {Object} 404 - { error: 'User not found' } + * @returns {Object} 500 - { error: string } + */ router.get('/:id', async (req, res) => { try { const { rows } = await pool.query( - `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login + `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login, + ivanti_first_name, ivanti_last_name FROM users WHERE id = $1`, [req.params.id] ); @@ -54,7 +81,21 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) { } }); - // Create new user + /** + * POST /api/users + * + * Creates a new user account. + * + * @body {string} username - Required. Unique username + * @body {string} email - Required. Unique email address + * @body {string} password - Required. Plain-text password (hashed before storage) + * @body {string} [group='Read_Only'] - Permission group (Admin, Standard_User, Leadership, Read_Only) + * @body {string} [bu_teams=''] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV) + * @returns {Object} 201 - { message: string, user: { id, username, email, group, bu_teams, teams } } + * @returns {Object} 400 - { error: string } if required fields missing or invalid group/teams + * @returns {Object} 409 - { error: string } if username or email already exists + * @returns {Object} 500 - { error: string } + */ router.post('/', async (req, res) => { const { username, email, password, group, bu_teams } = req.body; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; @@ -120,9 +161,28 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) { } }); - // Update user + /** + * PATCH /api/users/:id + * + * Updates one or more fields on an existing user account. Only provided fields are modified. + * + * @param {string} req.params.id - User ID to update + * @body {string} [username] - New username + * @body {string} [email] - New email address + * @body {string} [password] - New plain-text password (hashed before storage) + * @body {string} [group] - New permission group (Admin, Standard_User, Leadership, Read_Only) + * @body {boolean} [is_active] - Whether the account is active (deactivation deletes sessions) + * @body {string} [bu_teams] - Comma-separated BU team assignments (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV) + * @body {string} [ivanti_first_name] - Ivanti first name for finding correlation + * @body {string} [ivanti_last_name] - Ivanti last name for finding correlation + * @returns {Object} 200 - { message: 'User updated successfully' } + * @returns {Object} 400 - { error: string } if invalid group, self-demotion, self-deactivation, invalid teams, or no fields provided + * @returns {Object} 404 - { error: 'User not found' } + * @returns {Object} 409 - { error: string } if username or email already exists + * @returns {Object} 500 - { error: string } + */ router.patch('/:id', async (req, res) => { - const { username, email, password, group, is_active, bu_teams } = req.body; + const { username, email, password, group, is_active, bu_teams, ivanti_first_name, ivanti_last_name } = req.body; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; const userId = req.params.id; @@ -193,6 +253,14 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) { updates.push(`bu_teams = $${paramIndex++}`); values.push(bu_teams); } + if (typeof ivanti_first_name === 'string') { + updates.push(`ivanti_first_name = $${paramIndex++}`); + values.push(ivanti_first_name.trim() || null); + } + if (typeof ivanti_last_name === 'string') { + updates.push(`ivanti_last_name = $${paramIndex++}`); + values.push(ivanti_last_name.trim() || null); + } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); @@ -212,6 +280,8 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) { if (typeof is_active === 'boolean') updatedFields.is_active = is_active; if (password) updatedFields.password_changed = true; if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams; + if (typeof ivanti_first_name === 'string') updatedFields.ivanti_first_name = ivanti_first_name.trim() || null; + if (typeof ivanti_last_name === 'string') updatedFields.ivanti_last_name = ivanti_last_name.trim() || null; logAudit({ userId: req.user.id, @@ -270,7 +340,17 @@ function createUsersRouter(requireAuth, requireGroup, logAudit) { } }); - // Delete user + /** + * DELETE /api/users/:id + * + * Deletes a user account and their associated sessions. Cannot delete your own account. + * + * @param {string} req.params.id - User ID to delete + * @returns {Object} 200 - { message: 'User deleted successfully' } + * @returns {Object} 400 - { error: string } if attempting self-deletion + * @returns {Object} 404 - { error: 'User not found' } + * @returns {Object} 500 - { error: string } + */ router.delete('/:id', async (req, res) => { const userId = req.params.id; diff --git a/frontend/src/components/UserManagement.js b/frontend/src/components/UserManagement.js index a61bd2a..c5d11a6 100644 --- a/frontend/src/components/UserManagement.js +++ b/frontend/src/components/UserManagement.js @@ -181,7 +181,9 @@ export default function UserManagement({ onClose }) { email: '', password: '', group: 'Read_Only', - bu_teams: '' + bu_teams: '', + ivanti_first_name: '', + ivanti_last_name: '' }); const [formError, setFormError] = useState(''); const [formSuccess, setFormSuccess] = useState(''); @@ -241,7 +243,7 @@ export default function UserManagement({ onClose }) { setTimeout(() => { setShowAddUser(false); setEditingUser(null); - setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' }); + setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '', ivanti_first_name: '', ivanti_last_name: '' }); setFormSuccess(''); }, 1500); } catch (err) { @@ -280,7 +282,9 @@ export default function UserManagement({ onClose }) { email: user.email, password: '', group: user.group, - bu_teams: user.bu_teams || '' + bu_teams: user.bu_teams || '', + ivanti_first_name: user.ivanti_first_name || '', + ivanti_last_name: user.ivanti_last_name || '' }); setShowAddUser(true); setFormError(''); @@ -363,7 +367,7 @@ export default function UserManagement({ onClose }) { onClick={() => { setShowAddUser(true); setEditingUser(null); - setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' }); + setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '', ivanti_first_name: '', ivanti_last_name: '' }); setFormError(''); setFormSuccess(''); }} @@ -528,6 +532,32 @@ export default function UserManagement({ onClose }) {

+ {/* 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. +

+
+