Scope Atlas sync and metrics to active BU teams

Problem 1: Atlas sync was querying ALL host_ids from ivanti_findings
regardless of BU, writing 'no plan' entries for ACCESS-OPS hosts that
Atlas doesn't cover. Now the sync respects the user's active teams scope
(passed via query param) and falls back to IVANTI_MANAGED_BUS when no
scope is provided.

Problem 2: Atlas /metrics and /status endpoints returned unscoped data
from the full cache, so changing scope didn't update the Atlas Coverage
donut or badge counts. Both endpoints now accept a teams query param and
JOIN against ivanti_findings to scope results by BU.

Frontend changes:
- fetchAtlasStatus and fetchAtlasMetrics now pass teams param
- Atlas sync button passes active teams to the sync endpoint
- Scope change (adminScope) triggers Atlas data refresh

Also purged 6,461 polluted cache entries for non-managed BU hosts.
This commit is contained in:
Jordan Ramos
2026-06-12 12:38:45 -06:00
parent 356ce23462
commit 5105ee2ff8
2 changed files with 127 additions and 14 deletions

View File

@@ -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) {

View File

@@ -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');