// 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 pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const { isConfigured, missingVars, getTeams, getTeamAssets, getOwner, confirmAsset, declineAsset, redirectAsset, } = require('../helpers/cardApi'); // --------------------------------------------------------------------------- // 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); 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.' }); } } 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.' }); } return res.status(502).json({ error: 'CARD API request failed.', details: msg }); } // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- function createCardApiRouter() { const router = express.Router(); // GET /status router.get('/status', requireAuth(), (req, res) => { if (!isConfigured) { return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars }); } res.json({ configured: true }); }); // GET /teams router.get('/teams', requireAuth(), 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; } const teams = Array.isArray(body) ? body : (body && body.teams) || []; return res.json(teams); } 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 router.get('/teams/:teamName/assets', requireAuth(), 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; } 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({ 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 router.get('/owner/:assetId', requireAuth(), 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 router.post('/queue/:queueItemId/confirm', requireAuth(), 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; 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 { const { rows } = await pool.query( 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3', [queueItemId, req.user.id, 'CARD'] ); const item = rows[0]; 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.' }); } const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; logAudit({ 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.'; logAudit({ 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 }); } const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || ''); if (confirmResult.ok) { await pool.query( "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } logAudit({ 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 }); } const errMsg = `Confirm failed: HTTP ${confirmResult.status}`; logAudit({ 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) { logAudit({ 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 router.post('/queue/:queueItemId/decline', requireAuth(), 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; 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 { const { rows } = await pool.query( 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3', [queueItemId, req.user.id, 'CARD'] ); const item = rows[0]; 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.' }); } const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; logAudit({ 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.'; logAudit({ 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 }); } const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || ''); if (declineResult.ok) { await pool.query( "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } logAudit({ 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 }); } const errMsg = `Decline failed: HTTP ${declineResult.status}`; logAudit({ 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) { logAudit({ 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 router.post('/queue/:queueItemId/redirect', requireAuth(), 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; 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 { const { rows } = await pool.query( 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3', [queueItemId, req.user.id, 'CARD'] ); const item = rows[0]; 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.' }); } const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; logAudit({ 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.'; logAudit({ 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 }); } const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken); if (redirectResult.ok) { await pool.query( "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } logAudit({ 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 }); } const errMsg = `Redirect failed: HTTP ${redirectResult.status}`; logAudit({ 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) { logAudit({ 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;