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
This commit is contained in:
31
backend/migrations/add_user_ivanti_identity.js
Normal file
31
backend/migrations/add_user_ivanti_identity.js
Normal file
@@ -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();
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Object>} 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<string>} 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user