// 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, resolveAssetId, searchByIvantiHostId, } = 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 * * Returns whether the CARD API integration is configured. * * @response 200 - { configured: true } * @response 503 - { configured: false, error: string, missingVars: string[] } */ 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 * * Returns the list of teams from the CARD API. * * @response 200 - Array of team objects from CARD * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ 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 * * Returns paginated assets for a team filtered by disposition. * * @param {string} teamName - Team name (path parameter) * @query {string} disposition - Required. Asset disposition filter. * @query {number} [page] - Page number for pagination. * @query {number} [page_size=50] - Number of results per page. * @response 200 - { assets: object[], total: number, ... } from CARD * @response 400 - { error: string } — missing disposition * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ 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 * * Returns the CARD owner record for a given asset ID. * * @param {string} assetId - CARD asset identifier (path parameter) * @response 200 - CARD owner/asset object * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ 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 * * Confirms ownership of a CARD asset for a queue item. Fetches the owner * record to obtain the update_token, then calls the CARD confirm endpoint. * Marks the queue item as complete on success. * * @param {string} queueItemId - Queue item ID (path parameter) * @body {string} teamName - Team name to confirm ownership for (required) * @body {string} assetId - CARD asset identifier (required) * @body {string} [comment] - Optional comment for the confirmation * @response 200 - { success: true, cardResponse: object } * @response 400 - { error: string } — missing fields or item not pending * @response 404 - { error: string } — queue item not found * @response 502 - { error: string } — CARD API failure * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ 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: rawAssetId, comment } = req.body; if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) { return res.status(400).json({ error: 'assetId is required.' }); } // Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first let assetId = rawAssetId.trim(); if (!/\d+-[A-Z]+$/i.test(assetId)) { let resolved = null; // Fast path: look up the finding's host_id and search CARD directly const findingRow = await pool.query( 'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1', [assetId] ).then(r => r.rows[0]).catch(() => null); if (findingRow && findingRow.host_id) { try { const searchResult = await searchByIvantiHostId(findingRow.host_id); if (searchResult.ok) { const searchData = JSON.parse(searchResult.body); resolved = searchData._id || searchData.asset_id || searchData.id || null; } } catch (_) { /* fall through */ } } // Fallback: suffix guessing if (!resolved) { resolved = await resolveAssetId(assetId); } if (!resolved) { return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` }); } assetId = resolved; } 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 * * Declines ownership of a CARD asset for a queue item. Fetches the owner * record to obtain the update_token, then calls the CARD decline endpoint. * Marks the queue item as complete on success. * * @param {string} queueItemId - Queue item ID (path parameter) * @body {string} teamName - Team name declining ownership (required) * @body {string} assetId - CARD asset identifier (required) * @body {string} [comment] - Optional comment for the decline * @response 200 - { success: true, cardResponse: object } * @response 400 - { error: string } — missing fields or item not pending * @response 404 - { error: string } — queue item not found * @response 502 - { error: string } — CARD API failure * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ 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: rawAssetId, comment } = req.body; if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) { return res.status(400).json({ error: 'assetId is required.' }); } // Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first let assetId = rawAssetId.trim(); if (!/\d+-[A-Z]+$/i.test(assetId)) { let resolved = null; // Fast path: look up the finding's host_id and search CARD directly const findingRow = await pool.query( 'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1', [assetId] ).then(r => r.rows[0]).catch(() => null); if (findingRow && findingRow.host_id) { try { const searchResult = await searchByIvantiHostId(findingRow.host_id); if (searchResult.ok) { const searchData = JSON.parse(searchResult.body); resolved = searchData._id || searchData.asset_id || searchData.id || null; } } catch (_) { /* fall through */ } } // Fallback: suffix guessing if (!resolved) { resolved = await resolveAssetId(assetId); } if (!resolved) { return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` }); } assetId = resolved; } 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. The asset may have already been actioned or the owner record is in an unexpected state.'; 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, ownerResponse: ownerData }, 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 * * Redirects a CARD asset from one team to another for a queue item. Fetches * the owner record to obtain the update_token, then calls the CARD redirect * endpoint. Marks the queue item as complete on success. * * @param {string} queueItemId - Queue item ID (path parameter) * @body {string} fromTeam - Current owning team (required) * @body {string} toTeam - Target team to redirect to (required) * @body {string} assetId - CARD asset identifier (required) * @response 200 - { success: true, cardResponse: object } * @response 400 - { error: string } — missing fields or item not pending * @response 404 - { error: string } — queue item not found * @response 502 - { error: string } — CARD API failure * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ 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: rawAssetId } = 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 (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) { return res.status(400).json({ error: 'assetId is required.' }); } // Resolve bare IP to full CARD asset ID — try Ivanti host_id fast path first let assetId = rawAssetId.trim(); if (!/\d+-[A-Z]+$/i.test(assetId)) { let resolved = null; // Fast path: look up the finding's host_id and search CARD directly const findingRow = await pool.query( 'SELECT host_id FROM ivanti_findings WHERE ip_address = $1 LIMIT 1', [assetId] ).then(r => r.rows[0]).catch(() => null); if (findingRow && findingRow.host_id) { try { const searchResult = await searchByIvantiHostId(findingRow.host_id); if (searchResult.ok) { const searchData = JSON.parse(searchResult.body); resolved = searchData._id || searchData.asset_id || searchData.id || null; } } catch (_) { /* fall through */ } } // Fallback: suffix guessing if (!resolved) { resolved = await resolveAssetId(assetId); } if (!resolved) { return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` }); } assetId = resolved; } 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); } }); /** * GET /owner-lookup/:ip * * Resolve an IP to a CARD asset ID and return the full owner record. * Used by the CARD Action Modal to display ownership state before * confirm/decline/redirect operations. * * @param {string} ip - IP address (path parameter) * @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups. * @query {string} [hostId] - Ivanti Host ID (integer). When provided, uses CARD asset-search for faster resolution. * @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token } * @response 400 - { error: string } — missing IP * @response 404 - { error: string } — IP not found in CARD * @response 504 - { error: string, timeout: true } — CARD lookup timed out * @response 503 - { error: string } — CARD not configured */ router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const ip = req.params.ip; if (!ip || !ip.trim()) { return res.status(400).json({ error: 'IP address is required.' }); } // Use quick mode (CTEC only, 15s timeout) for tooltip lookups const quick = req.query.quick === '1'; const hostId = req.query.hostId; // Fast path: if Ivanti hostId is provided, try asset-search first let assetId = null; if (hostId && /^\d+$/.test(hostId)) { try { const searchResult = await searchByIvantiHostId(hostId); if (searchResult.ok) { const searchData = JSON.parse(searchResult.body); // Extract asset_id from the search response (_id field or asset_id) assetId = searchData._id || searchData.asset_id || searchData.id || null; } } catch (_) { // Fall through to suffix resolution } } // Fallback: resolve via IP + suffix guessing if (!assetId) { try { assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined); } catch (err) { if (err.message === 'CARD_TIMEOUT') { return res.status(504).json({ error: 'CARD lookup timed out', timeout: true }); } return handleCardError(err, res); } } if (!assetId) { return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` }); } // Fetch full owner record try { const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { return res.status(ownerResult.status).json({ error: `Failed to fetch owner: HTTP ${ownerResult.status}` }); } const data = JSON.parse(ownerResult.body); const owner = data.owner || {}; res.json({ asset_id: assetId, ip: ip.trim(), confirmed: owner.confirmed || null, unconfirmed: owner.unconfirmed || null, declined: owner.declined || [], candidate: owner.candidate || [], update_token: owner.update_token || null, }); } catch (err) { return handleCardError(err, res); } }); /** * POST /owner/:assetId/confirm * * Directly confirm ownership of a CARD asset (no queue item required). * Fetches the owner record for the update_token, then calls CARD confirm. * * @param {string} assetId - CARD asset identifier or IP address (path parameter) * @body {string} teamName - Team to confirm ownership for (required) * @body {string} [comment] - Optional comment * @response 200 - { success: true, cardResponse: object } * @response 400 - { error: string } — missing fields * @response 404 - { error: string } — asset not found * @response 502 - { error: string } — CARD API failure * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } let assetId = req.params.assetId; const { teamName, comment } = req.body || {}; if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } // Resolve bare IP to full CARD asset ID if (!/\d+-[A-Z]+$/i.test(assetId)) { const resolved = await resolveAssetId(assetId); if (!resolved) { return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` }); } assetId = resolved; } try { const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` }); } let ownerData; try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } const updateToken = ownerData.owner && ownerData.owner.update_token; if (!updateToken) { return res.status(502).json({ error: 'update_token not found in owner record.' }); } const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || ''); if (confirmResult.ok) { let cardResponse; try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip }); return res.json({ success: true, cardResponse }); } let errorBody; try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; } return res.status(confirmResult.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); /** * POST /owner/:assetId/decline * * Directly decline ownership of a CARD asset (no queue item required). * Fetches the owner record for the update_token, then calls CARD decline. * * @param {string} assetId - CARD asset identifier or IP address (path parameter) * @body {string} teamName - Team to decline ownership for (required) * @body {string} [comment] - Optional comment * @response 200 - { success: true, cardResponse: object } * @response 400 - { error: string } — missing fields * @response 404 - { error: string } — asset not found * @response 502 - { error: string } — CARD API failure * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } let assetId = req.params.assetId; const { teamName, comment } = req.body || {}; if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } if (!/\d+-[A-Z]+$/i.test(assetId)) { const resolved = await resolveAssetId(assetId); if (!resolved) { return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` }); } assetId = resolved; } try { const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` }); } let ownerData; try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } const updateToken = ownerData.owner && ownerData.owner.update_token; if (!updateToken) { return res.status(502).json({ error: 'update_token not found in owner record.' }); } const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || ''); if (declineResult.ok) { let cardResponse; try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip }); return res.json({ success: true, cardResponse }); } let errorBody; try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; } return res.status(declineResult.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); /** * POST /owner/:assetId/redirect * * Directly redirect a CARD asset between teams (no queue item required). * Fetches the owner record for the update_token, then calls CARD redirect. * * @param {string} assetId - CARD asset identifier or IP address (path parameter) * @body {string} fromTeam - Current owning team (required) * @body {string} toTeam - Target team (required) * @response 200 - { success: true, cardResponse: object } * @response 400 - { error: string } — missing fields * @response 404 - { error: string } — asset not found * @response 502 - { error: string } — CARD API failure * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } let assetId = req.params.assetId; const { fromTeam, toTeam } = 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 (!/\d+-[A-Z]+$/i.test(assetId)) { const resolved = await resolveAssetId(assetId); if (!resolved) { return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` }); } assetId = resolved; } try { const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` }); } let ownerData; try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } const updateToken = ownerData.owner && ownerData.owner.update_token; if (!updateToken) { return res.status(502).json({ error: 'update_token not found in owner record.' }); } const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken); if (redirectResult.ok) { let cardResponse; try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip }); return res.json({ success: true, cardResponse }); } let errorBody; try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; } return res.status(redirectResult.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); /** * GET /asset-search/:hostId * * Search CARD by Ivanti Asset ID (integer). Uses CARD's v2 asset-search * endpoint with deep_search to find the associated CARD asset directly, * bypassing the IP + suffix guessing flow. * * @param {string} hostId - Ivanti Host ID (8-digit integer, from ivanti_findings.host_id) * @response 200 - CARD asset record * @response 400 - { error: string } — invalid host ID * @response 404 - { error: string } — asset not found * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ router.get('/asset-search/:hostId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { hostId } = req.params; if (!hostId || !/^\d+$/.test(hostId.trim())) { return res.status(400).json({ error: 'hostId must be a numeric Ivanti Asset ID.' }); } try { const result = await searchByIvantiHostId(hostId.trim()); if (result.ok) { let body; try { body = JSON.parse(result.body); } catch (_) { body = result.body; } logAudit({ userId: req.user.id, username: req.user.username, action: 'card_asset_search', entityType: 'card_asset', entityId: hostId.trim(), details: { search_type: 'ivanti_host_id' }, ipAddress: req.ip, }); return res.json(body); } if (result.status === 404) { return res.status(404).json({ error: `No CARD asset found for Ivanti Host ID: ${hostId}` }); } 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 /enrich-batch * * Batch lookup IPs in CARD to extract Granite loader fields. Fetches team * assets (paginated, across confirmed, unconfirmed, and candidate * dispositions) and matches against the provided IPs. When no team is * specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG. * Returns enrichment results for each IP. * * @body {string[]} ips - Non-empty array of IP address strings (max 200) * @body {string} [team] - Team name to search assets under. Defaults to both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG if omitted. * @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number } * Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string } * @response 400 - { error: string } — invalid or empty ips array, or exceeds 200 * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } const { ips, team } = req.body || {}; if (!Array.isArray(ips) || ips.length === 0) { return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' }); } if (ips.length > 200) { return res.status(400).json({ error: 'Maximum 200 IPs per request.' }); } // Build a set of IPs we're looking for const targetIps = new Set(ips.map(ip => (ip || '').trim()).filter(Boolean)); const resultMap = {}; // Strategy: fetch team assets (paginated) and match against our target IPs. // The team assets endpoint returns the full enriched record with ncim_discovery, // card_flags, netops_granite_allips, etc. const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG']; const dispositions = ['confirmed', 'unconfirmed', 'candidate']; let foundCount = 0; for (const teamName of teams) { if (foundCount >= targetIps.size) break; for (const disposition of dispositions) { if (foundCount >= targetIps.size) break; let page = 1; const pageSize = 200; let hasMore = true; while (hasMore && foundCount < targetIps.size) { try { const result = await getTeamAssets(teamName, { disposition, page, pageSize }); if (!result.ok) break; const data = JSON.parse(result.body); const assets = data.assets || data.results || (Array.isArray(data) ? data : []); if (assets.length === 0) { hasMore = false; break; } // Match assets against target IPs for (const asset of assets) { const assetId = asset._id || ''; // Extract IP from asset ID (e.g., "10.240.78.110-CTEC" → "10.240.78.110") const assetIp = assetId.replace(/-[A-Z]+$/i, ''); if (targetIps.has(assetIp) && !resultMap[assetIp]) { resultMap[assetIp] = extractGraniteFields(asset, assetIp); foundCount++; } } // Check if there are more pages const total = data.total || data.count || 0; if (page * pageSize >= total || assets.length < pageSize) { hasMore = false; } else { page++; } } catch (err) { console.error(`[card-api] enrich-batch: Error fetching ${teamName}/${disposition} page ${page}:`, err.message); hasMore = false; } } } } // Build results array in the same order as input IPs const results = []; let enrichedCount = 0; let notFoundCount = 0; for (const ip of ips) { const trimmedIp = (ip || '').trim(); if (!trimmedIp) { results.push({ ip: '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' }); notFoundCount++; continue; } if (resultMap[trimmedIp]) { results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] }); enrichedCount++; } else { results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD team assets' }); notFoundCount++; } } res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length }); }); return router; } /** * Extract Granite-relevant fields from a CARD asset/owner record. * The owner endpoint returns: owner (confirmed/unconfirmed/etc), card_flags, tmp. * ncim_discovery is only available from the team assets endpoint. */ function extractGraniteFields(asset, ip) { // card_flags can be an array or nested in the response differently let flags = {}; if (asset.card_flags) { flags = Array.isArray(asset.card_flags) ? (asset.card_flags[0] || {}) : asset.card_flags; } // Also check if flags are at the top level (owner endpoint format) if (!flags.CARD_HOSTNAME && asset.CARD_HOSTNAME) { flags = asset; } const ncim = asset.ncim_discovery || []; const granite = asset.netops_granite_allips || []; const iseGranite = asset.ise_granite_equipment || []; const qualys = asset.qualys_hosts || []; const ivanti = asset.ivanti_assets || []; // EQUIP_INST_ID — from ncim_discovery or granite (only available from team assets endpoint) let equip_inst_id = null; let site_name = null; let responsible_team = null; let hostname = null; if (ncim.length > 0) { equip_inst_id = ncim[0].EQUIP_INST_ID || null; responsible_team = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null; site_name = ncim[0].SITE_NAME || ncim[0].SITENAME || null; hostname = ncim[0].HOSTNAME || null; } if (!equip_inst_id && Array.isArray(granite) && granite.length > 0) { equip_inst_id = granite[0].EQUIP_INST_ID || null; if (!site_name) site_name = granite[0].SITE_NAME || null; if (!responsible_team) responsible_team = granite[0].RESPONSIBLE_TEAM || null; } if (!equip_inst_id && Array.isArray(iseGranite) && iseGranite.length > 0) { equip_inst_id = iseGranite[0].EQUIP_INST_ID || null; } // Hostname from card_flags (primary source from owner endpoint) if (!hostname && flags.CARD_HOSTNAME) { const hostnames = Array.isArray(flags.CARD_HOSTNAME) ? flags.CARD_HOSTNAME : [flags.CARD_HOSTNAME]; hostname = hostnames[0] || null; } if (!hostname && qualys.length > 0) hostname = qualys[0].HOSTNAME || null; if (!hostname && ivanti.length > 0) hostname = ivanti[0].hostName || null; // ASN from card_flags const mgmt_ip_asn = flags.CARD_ASN || null; // CLLI → can be used as site hint if (!site_name && flags.CARD_CLLI) { site_name = flags.CARD_CLLI; // CLLI code, not full site name — user may need to map } // Equipment class — always S (Shelf) from CARD context const equipment_class = 'S'; // Device ID → maps to SERIALNUMBER in Granite const serial_number = flags.CARD_DEVICE_ID || null; // Equip status from flags const equip_status = flags.status || null; // Vendor/model from card_flags let equip_template = null; if (flags.CARD_VENDOR_MODEL && Array.isArray(flags.CARD_VENDOR_MODEL) && flags.CARD_VENDOR_MODEL.length > 0) { const vm = flags.CARD_VENDOR_MODEL[0]; equip_template = typeof vm === 'string' ? vm : (vm.vendor_model || null); } // Confirmed team from owner record const confirmed_team = asset.owner && asset.owner.confirmed ? asset.owner.confirmed.name : null; return { equip_inst_id: equip_inst_id ? String(equip_inst_id) : null, hostname, site_name, mgmt_ip_asn: mgmt_ip_asn ? String(mgmt_ip_asn) : null, responsible_team: responsible_team || confirmed_team || null, equipment_class, equip_template, equip_status, serial_number, }; } module.exports = createCardApiRouter;