// CARD Asset Ownership API Routes // Proxies CARD operations (confirm, decline, redirect, search) and orchestrates // the two-step update_token flow for mutations. const express = require('express'); const { requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const { isConfigured, missingVars, getTeams, getTeamAssets, getOwner, confirmAsset, declineAsset, redirectAsset, } = require('../helpers/cardApi'); // --------------------------------------------------------------------------- // DB helpers — promise wrappers for callback-based SQLite API // --------------------------------------------------------------------------- function dbRun(db, sql, params = []) { return new Promise((resolve, reject) => { db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); }); }); } function dbGet(db, sql, params = []) { return new Promise((resolve, reject) => { db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); }); } // --------------------------------------------------------------------------- // Error classification — maps CARD API / token errors to client responses // --------------------------------------------------------------------------- function handleCardError(err, res) { const msg = err.message || String(err); console.error('[card-api]', msg); // Token endpoint errors (from acquireToken rejections) if (msg.includes('Token acquisition failed')) { if (msg.includes('HTTP 401')) { return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' }); } if (msg.includes('HTTP 403')) { return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' }); } if (msg.includes('HTTP 525')) { return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' }); } } // API call errors (after automatic 401 retry in helper) if (msg.includes('401')) { return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' }); } if (msg.includes('403')) { return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' }); } // Catch-all return res.status(502).json({ error: 'CARD API request failed.', details: msg }); } // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- function createCardApiRouter(db, requireAuth) { const router = express.Router(); // ------------------------------------------------------------------- // GET /status // Returns whether the CARD API integration is configured. // ------------------------------------------------------------------- router.get('/status', requireAuth(db), (req, res) => { if (!isConfigured) { return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars, }); } res.json({ configured: true }); }); // ------------------------------------------------------------------- // GET /teams // Proxy CARD teams list. // ------------------------------------------------------------------- router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } try { const result = await getTeams(); if (result.ok) { let body; try { body = JSON.parse(result.body); } catch (_) { body = result.body; } // CARD API wraps teams in { teams: [...], response_time: ... } const teams = Array.isArray(body) ? body : (body && body.teams) || []; return res.json(teams); } // Forward CARD error status let errorBody; try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; } return res.status(result.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); // ------------------------------------------------------------------- // GET /teams/:teamName/assets // Proxy team assets with required disposition filter. // ------------------------------------------------------------------- router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { teamName } = req.params; const { disposition, page, page_size } = req.query; if (!disposition) { return res.status(400).json({ error: 'disposition query parameter is required.' }); } try { const result = await getTeamAssets(teamName, { disposition, page: page ? parseInt(page, 10) : undefined, pageSize: page_size ? parseInt(page_size, 10) : 50, }); if (result.ok) { let body; try { body = JSON.parse(result.body); } catch (_) { body = result.body; } // Audit log for asset search (fire-and-forget) let resultCount = 0; if (body && typeof body === 'object' && typeof body.total === 'number') { resultCount = body.total; } else if (body && Array.isArray(body.assets)) { resultCount = body.assets.length; } logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_search', entityType: 'card_asset', entityId: teamName, details: { disposition, resultCount }, ipAddress: req.ip, }); return res.json(body); } let errorBody; try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; } return res.status(result.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); // ------------------------------------------------------------------- // GET /owner/:assetId // Proxy owner record lookup. // ------------------------------------------------------------------- router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { assetId } = req.params; try { const result = await getOwner(assetId); if (result.ok) { let body; try { body = JSON.parse(result.body); } catch (_) { body = result.body; } return res.json(body); } let errorBody; try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; } return res.status(result.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); // ------------------------------------------------------------------- // POST /queue/:queueItemId/confirm // Confirm asset to a team via CARD API. // ------------------------------------------------------------------- router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { queueItemId } = req.params; const { teamName, assetId, comment } = req.body; // Validate required fields if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } if (!assetId || typeof assetId !== 'string' || !assetId.trim()) { return res.status(400).json({ error: 'assetId is required.' }); } try { // Validate queue item const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?', [queueItemId, req.user.id, 'CARD'] ); if (!item) { return res.status(404).json({ error: 'Queue item not found.' }); } if (item.status !== 'pending') { return res.status(400).json({ error: 'Only pending queue items can be executed.' }); } // Step 1: Get owner record for update_token const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip, }); let errorBody; try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } return res.status(ownerResult.status).json(errorBody); } let ownerData; try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } const updateToken = ownerData.owner && ownerData.owner.update_token; if (!updateToken) { const errMsg = 'update_token not found in owner record.'; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip, }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); } // Step 2: Execute confirm mutation const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || ''); if (confirmResult.ok) { // Update queue item to complete await dbRun(db, "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } // Audit log (fire-and-forget) logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_confirm', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, ipAddress: req.ip, }); return res.json({ success: true, cardResponse }); } // Mutation failed — leave queue item as pending const errMsg = `Confirm failed: HTTP ${confirmResult.status}`; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, ipAddress: req.ip, }); let errorBody; try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; } return res.status(confirmResult.status).json(errorBody); } catch (err) { console.error('[card-api] Confirm error:', err.message); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip, }); return handleCardError(err, res); } }); // ------------------------------------------------------------------- // POST /queue/:queueItemId/decline // Decline asset from a team via CARD API. // ------------------------------------------------------------------- router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { queueItemId } = req.params; const { teamName, assetId, comment } = req.body; // Validate required fields if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } if (!assetId || typeof assetId !== 'string' || !assetId.trim()) { return res.status(400).json({ error: 'assetId is required.' }); } try { // Validate queue item const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?', [queueItemId, req.user.id, 'CARD'] ); if (!item) { return res.status(404).json({ error: 'Queue item not found.' }); } if (item.status !== 'pending') { return res.status(400).json({ error: 'Only pending queue items can be executed.' }); } // Step 1: Get owner record for update_token const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip, }); let errorBody; try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } return res.status(ownerResult.status).json(errorBody); } let ownerData; try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } const updateToken = ownerData.owner && ownerData.owner.update_token; if (!updateToken) { const errMsg = 'update_token not found in owner record.'; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip, }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); } // Step 2: Execute decline mutation const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || ''); if (declineResult.ok) { await dbRun(db, "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_decline', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, ipAddress: req.ip, }); return res.json({ success: true, cardResponse }); } // Mutation failed const errMsg = `Decline failed: HTTP ${declineResult.status}`; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, ipAddress: req.ip, }); let errorBody; try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; } return res.status(declineResult.status).json(errorBody); } catch (err) { console.error('[card-api] Decline error:', err.message); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip, }); return handleCardError(err, res); } }); // ------------------------------------------------------------------- // POST /queue/:queueItemId/redirect // Redirect asset from one team to another via CARD API. // ------------------------------------------------------------------- router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { queueItemId } = req.params; const { fromTeam, toTeam, assetId } = req.body; // Validate required fields if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) { return res.status(400).json({ error: 'fromTeam is required.' }); } if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) { return res.status(400).json({ error: 'toTeam is required.' }); } if (!assetId || typeof assetId !== 'string' || !assetId.trim()) { return res.status(400).json({ error: 'assetId is required.' }); } try { // Validate queue item const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?', [queueItemId, req.user.id, 'CARD'] ); if (!item) { return res.status(404).json({ error: 'Queue item not found.' }); } if (item.status !== 'pending') { return res.status(400).json({ error: 'Only pending queue items can be executed.' }); } // Step 1: Get owner record for update_token const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip, }); let errorBody; try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } return res.status(ownerResult.status).json(errorBody); } let ownerData; try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } const updateToken = ownerData.owner && ownerData.owner.update_token; if (!updateToken) { const errMsg = 'update_token not found in owner record.'; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip, }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); } // Step 2: Execute redirect mutation const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken); if (redirectResult.ok) { await dbRun(db, "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_redirect', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, ipAddress: req.ip, }); return res.json({ success: true, cardResponse }); } // Mutation failed const errMsg = `Redirect failed: HTTP ${redirectResult.status}`; console.error('[card-api]', errMsg); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, ipAddress: req.ip, }); let errorBody; try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; } return res.status(redirectResult.status).json(errorBody); } catch (err) { console.error('[card-api] Redirect error:', err.message); logAudit(db, { userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip, }); return handleCardError(err, res); } }); return router; } module.exports = createCardApiRouter;