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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user