From e34f9e567cedff753fa6f16420963225717003e1 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 24 Jun 2026 13:41:16 -0600 Subject: [PATCH] Extend team enforcement to Atlas and Archive routes, update schema reference - Atlas: add requireTeam() at router level; replace client ?teams= param parsing with req.teamScope in /metrics, /status, and /sync endpoints - Archive: add requireTeam() at router level; replace client ?teams= param parsing with req.teamScope in GET / and GET /stats endpoints - db-schema.sql: add impersonate_user_id column to sessions table reference The frontend still sends ?teams= as a query param to these endpoints (harmless no-op since backend ignores it). Frontend cleanup deferred to avoid churn in the 7000-line ReportingPage component. --- backend/db-schema.sql | 3 +- backend/routes/atlas.js | 148 ++++++++++++-------------------- backend/routes/ivantiArchive.js | 30 +++---- 3 files changed, 71 insertions(+), 110 deletions(-) diff --git a/backend/db-schema.sql b/backend/db-schema.sql index 196c7c1..ef0100a 100644 --- a/backend/db-schema.sql +++ b/backend/db-schema.sql @@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS sessions ( session_id VARCHAR(255) UNIQUE NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() + created_at TIMESTAMPTZ DEFAULT NOW(), + impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id); diff --git a/backend/routes/atlas.js b/backend/routes/atlas.js index 91a176f..d18c671 100644 --- a/backend/routes/atlas.js +++ b/backend/routes/atlas.js @@ -4,7 +4,7 @@ const express = require('express'); const pool = require('../db'); -const { requireAuth, requireGroup } = require('../middleware/auth'); +const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); @@ -70,49 +70,44 @@ function aggregateAtlasMetrics(rows) { function createAtlasRouter() { const router = express.Router(); + // All atlas routes require authentication and team scoping + router.use(requireAuth()); + router.use(requireTeam()); + /** * 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). + * Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs. * - * @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 */ - router.get('/metrics', requireAuth(), async (req, res) => { + router.get('/metrics', async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } try { - 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 - WHERE a.atlas_known = true`, - [patterns] - ); - rows = result.rows; - } else { - const result = await pool.query( - `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true` - ); - rows = result.rows; - } + if (req.teamScope) { + // Non-admin: scope to user's team findings + const patterns = req.teamScope.ivanti.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 + WHERE a.atlas_known = true`, + [patterns] + ); + rows = result.rows; } else { + // Admin bypass — all cached plans const result = await pool.query( `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true` ); @@ -131,44 +126,35 @@ function createAtlasRouter() { * GET /status * * 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). + * Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs. * - * @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, atlas_known, synced_at } * @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 500 - { error } on database failure */ - router.get('/status', requireAuth(), async (req, res) => { + router.get('/status', async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } try { - 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.atlas_known, 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, atlas_known, synced_at FROM atlas_action_plans_cache` - ); - rows = result.rows; - } + if (req.teamScope) { + // Non-admin: scope to user's team findings + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + const result = await pool.query( + `SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, 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 { + // Admin bypass — all cached entries const result = await pool.query( `SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache` ); @@ -187,64 +173,40 @@ function createAtlasRouter() { * * Syncs action plan data from Atlas for all hosts found in ivanti_findings. * Fetches plans per host in batches of 5 and upserts into the local cache. - * Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS. + * Team scoping enforced by requireTeam() — syncs only hosts in user's BUs. + * Falls back to IVANTI_MANAGED_BUS for admin when no team scope is set. * Requires Admin or Standard_User group. * - * @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG') - * @param {Object} [req.body] - * @param {string} [req.body.teams] - Comma-separated team names (alternative to query param) * @returns {Object} 200 - { synced, withPlans, failed } * @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 500 - { error } on unexpected failure */ - router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } try { - // 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 || ''; + // Use team scope from middleware, fall back to managed BUs for admin 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; - } + let patterns; + if (req.teamScope) { + patterns = req.teamScope.ivanti.map(t => `%${t}%`); } 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; + // Admin with no specific scope — sync managed BUs + 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] + ); + const findingsRows = result.rows; + const hostIds = findingsRows.map(r => r.host_id); if (hostIds.length === 0) { diff --git a/backend/routes/ivantiArchive.js b/backend/routes/ivantiArchive.js index eb63375..d9489b2 100644 --- a/backend/routes/ivantiArchive.js +++ b/backend/routes/ivantiArchive.js @@ -1,7 +1,7 @@ // Ivanti Archive Routes — list, stats, and transition history for archived findings const express = require('express'); const pool = require('../db'); -const { requireAuth } = require('../middleware/auth'); +const { requireAuth, requireTeam } = require('../middleware/auth'); const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; @@ -30,18 +30,18 @@ function findRelatedActive(archive, activeFindings) { function createIvantiArchiveRouter() { const router = express.Router(); - // All routes require authentication + // All routes require authentication and team scoping router.use(requireAuth()); + router.use(requireTeam()); /** * GET / - * List archive records with optional state and teams filtering. + * List archive records with optional state filtering. + * Team scoping enforced by requireTeam() middleware via req.teamScope. * * @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED. * When state=ACTIVE, returns live open findings from ivanti_findings instead of archives. * When state=CLOSED, includes both CLOSED and CLOSED_GONE records. - * @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG'). - * Filters results to findings whose bu_ownership contains one of the specified teams. * * @response {object} 200 * { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number } @@ -51,7 +51,7 @@ function createIvantiArchiveRouter() { * { error: string } */ router.get('/', async (req, res) => { - const { state, teams } = req.query; + const { state } = req.query; if (state && !VALID_STATES.includes(state)) { return res.status(400).json({ @@ -59,9 +59,9 @@ function createIvantiArchiveRouter() { }); } - // Parse teams filter into ILIKE patterns - const teamPatterns = teams - ? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') + // Build team patterns from middleware (null = admin, no filter) + const teamPatterns = req.teamScope + ? req.teamScope.ivanti.map(t => `%${t}%`) : []; try { @@ -148,9 +148,7 @@ function createIvantiArchiveRouter() { /** * GET /stats * Summary counts of archive records grouped by lifecycle state. - * - * @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG'). - * Filters counts to findings whose bu_ownership contains one of the specified teams. + * Team scoping enforced by requireTeam() middleware via req.teamScope. * * @response {object} 200 * { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number } @@ -159,9 +157,9 @@ function createIvantiArchiveRouter() { */ router.get('/stats', async (req, res) => { try { - const { teams } = req.query; - const teamPatterns = teams - ? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') + // Build team patterns from middleware (null = admin, no filter) + const teamPatterns = req.teamScope + ? req.teamScope.ivanti.map(t => `%${t}%`) : []; let archiveQuery, archiveParams = []; @@ -190,7 +188,7 @@ function createIvantiArchiveRouter() { } } - // ACTIVE = total live findings count (scoped by teams if provided) + // ACTIVE = total live findings count (scoped by teams) let activeQuery, activeParams = []; if (teamPatterns.length > 0) { activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`;