diff --git a/backend/routes/atlas.js b/backend/routes/atlas.js index ec76038..90df553 100644 --- a/backend/routes/atlas.js +++ b/backend/routes/atlas.js @@ -74,7 +74,10 @@ function createAtlasRouter() { * GET /metrics * * Returns aggregated Atlas action plan metrics from the local cache. + * Accepts optional `teams` query parameter to scope metrics to hosts + * belonging to specific BUs (via JOIN on ivanti_findings). * + * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans } * @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 500 - { error } on database failure @@ -85,9 +88,36 @@ function createAtlasRouter() { } try { - const { rows } = await pool.query( - `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` - ); + const teamsParam = req.query.teams; + let rows; + + if (teamsParam) { + const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); + if (teams.length > 0) { + const patterns = teams.map(t => `%${t}%`); + const result = await pool.query( + `SELECT a.has_action_plan, a.plans_json + FROM atlas_action_plans_cache a + INNER JOIN ( + SELECT DISTINCT host_id FROM ivanti_findings + WHERE bu_ownership ILIKE ANY($1::text[]) + ) f ON a.host_id = f.host_id`, + [patterns] + ); + rows = result.rows; + } else { + const result = await pool.query( + `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` + ); + rows = result.rows; + } + } else { + const result = await pool.query( + `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` + ); + rows = result.rows; + } + const metrics = aggregateAtlasMetrics(rows); res.json(metrics); } catch (err) { @@ -99,8 +129,11 @@ function createAtlasRouter() { /** * GET /status * - * Returns the full atlas_action_plans_cache table contents for status display. + * Returns atlas_action_plans_cache contents for status display. + * Accepts optional `teams` query parameter to scope results to hosts + * belonging to specific BUs (via JOIN on ivanti_findings). * + * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, synced_at } * @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 500 - { error } on database failure @@ -111,9 +144,36 @@ function createAtlasRouter() { } try { - const { rows } = await pool.query( - `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` - ); + const teamsParam = req.query.teams; + let rows; + + if (teamsParam) { + const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); + if (teams.length > 0) { + const patterns = teams.map(t => `%${t}%`); + const result = await pool.query( + `SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.synced_at + FROM atlas_action_plans_cache a + INNER JOIN ( + SELECT DISTINCT host_id FROM ivanti_findings + WHERE bu_ownership ILIKE ANY($1::text[]) + ) f ON a.host_id = f.host_id`, + [patterns] + ); + rows = result.rows; + } else { + const result = await pool.query( + `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` + ); + rows = result.rows; + } + } else { + const result = await pool.query( + `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` + ); + rows = result.rows; + } + res.json(rows); } catch (err) { console.error('[Atlas] Error fetching status:', err.message); @@ -138,10 +198,48 @@ function createAtlasRouter() { } try { - // Read Ivanti findings and extract unique non-null hostIds - const { rows: findingsRows } = await pool.query( - `SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0` - ); + // Scope sync to the user's active teams if provided, otherwise sync only + // findings from managed BUs (IVANTI_MANAGED_BUS) to avoid polluting cache + // with "no plan" entries for BUs not covered by Atlas. + const teamsParam = req.query.teams || req.body.teams || ''; + const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM') + .split(',').map(b => b.trim()).filter(Boolean); + + let findingsRows; + if (teamsParam) { + const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); + if (teams.length > 0) { + const patterns = teams.map(t => `%${t}%`); + const result = await pool.query( + `SELECT DISTINCT host_id FROM ivanti_findings + WHERE host_id IS NOT NULL AND host_id > 0 + AND bu_ownership ILIKE ANY($1::text[])`, + [patterns] + ); + findingsRows = result.rows; + } else { + // No valid teams — fall back to managed BUs + const patterns = managedBUs.map(b => `%${b}%`); + const result = await pool.query( + `SELECT DISTINCT host_id FROM ivanti_findings + WHERE host_id IS NOT NULL AND host_id > 0 + AND bu_ownership ILIKE ANY($1::text[])`, + [patterns] + ); + findingsRows = result.rows; + } + } else { + // No teams specified — default to managed BUs only + const patterns = managedBUs.map(b => `%${b}%`); + const result = await pool.query( + `SELECT DISTINCT host_id FROM ivanti_findings + WHERE host_id IS NOT NULL AND host_id > 0 + AND bu_ownership ILIKE ANY($1::text[])`, + [patterns] + ); + findingsRows = result.rows; + } + const hostIds = findingsRows.map(r => r.host_id); if (hostIds.length === 0) { diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index e2942d0..6b8fdca 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -6142,7 +6142,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { const fetchAtlasStatus = useCallback(async () => { try { - const res = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' }); + const teamsParam = getActiveTeamsParam(); + const url = teamsParam + ? `${API_BASE}/atlas/status?teams=${encodeURIComponent(teamsParam)}` + : `${API_BASE}/atlas/status`; + const res = await fetch(url, { credentials: 'include' }); if (res.ok) { const data = await res.json(); const map = new Map(); @@ -6158,7 +6162,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { setAtlasMetricsLoading(true); setAtlasMetricsError(null); try { - const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' }); + const teamsParam = getActiveTeamsParam(); + const url = teamsParam + ? `${API_BASE}/atlas/metrics?teams=${encodeURIComponent(teamsParam)}` + : `${API_BASE}/atlas/metrics`; + const res = await fetch(url, { credentials: 'include' }); if (res.ok) { const data = await res.json(); setAtlasMetrics(data); @@ -6269,6 +6277,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { .catch(() => {}); // Also refresh FP workflow counts for the new scope fetchFPWorkflowCounts(); + // Refresh Atlas data for the new scope + fetchAtlasStatus(); + fetchAtlasMetrics(); }, [adminScope]); // eslint-disable-line // Set/clear a single column filter @@ -7185,7 +7196,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { setAtlasSyncing(true); setAtlasError(null); try { - const res = await fetch(`${API_BASE}/atlas/sync`, { method: 'POST', credentials: 'include' }); + const teamsParam = getActiveTeamsParam(); + const syncUrl = teamsParam + ? `${API_BASE}/atlas/sync?teams=${encodeURIComponent(teamsParam)}` + : `${API_BASE}/atlas/sync`; + const res = await fetch(syncUrl, { method: 'POST', credentials: 'include' }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || 'Atlas sync failed');