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.
This commit is contained in:
Jordan Ramos
2026-06-24 13:41:16 -06:00
parent 0e17318cba
commit e34f9e567c
3 changed files with 71 additions and 110 deletions

View File

@@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS sessions (
session_id VARCHAR(255) UNIQUE NOT NULL, session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL, 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); CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);

View File

@@ -4,7 +4,7 @@
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
@@ -70,49 +70,44 @@ function aggregateAtlasMetrics(rows) {
function createAtlasRouter() { function createAtlasRouter() {
const router = express.Router(); const router = express.Router();
// All atlas routes require authentication and team scoping
router.use(requireAuth());
router.use(requireTeam());
/** /**
* GET /metrics * GET /metrics
* *
* Returns aggregated Atlas action plan metrics from the local cache. * Returns aggregated Atlas action plan metrics from the local cache.
* Accepts optional `teams` query parameter to scope metrics to hosts * Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs.
* 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} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
* @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 503 - { error } when Atlas API is not configured
* @returns {Object} 500 - { error } on database failure * @returns {Object} 500 - { error } on database failure
*/ */
router.get('/metrics', requireAuth(), async (req, res) => { router.get('/metrics', async (req, res) => {
if (!isConfigured) { 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.' }); 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 { try {
const teamsParam = req.query.teams;
let rows; let rows;
if (teamsParam) { if (req.teamScope) {
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); // Non-admin: scope to user's team findings
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); const result = await pool.query(
const result = await pool.query( `SELECT a.has_action_plan, a.plans_json
`SELECT a.has_action_plan, a.plans_json FROM atlas_action_plans_cache a
FROM atlas_action_plans_cache a INNER JOIN (
INNER JOIN ( SELECT DISTINCT host_id FROM ivanti_findings
SELECT DISTINCT host_id FROM ivanti_findings WHERE bu_ownership ILIKE ANY($1::text[])
WHERE bu_ownership ILIKE ANY($1::text[]) ) f ON a.host_id = f.host_id
) f ON a.host_id = f.host_id WHERE a.atlas_known = true`,
WHERE a.atlas_known = true`, [patterns]
[patterns] );
); rows = result.rows;
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;
}
} else { } else {
// Admin bypass — all cached plans
const result = await pool.query( const result = await pool.query(
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true` `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
); );
@@ -131,44 +126,35 @@ function createAtlasRouter() {
* GET /status * GET /status
* *
* Returns atlas_action_plans_cache contents for status display. * Returns atlas_action_plans_cache contents for status display.
* Accepts optional `teams` query parameter to scope results to hosts * Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs.
* 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, atlas_known, synced_at } * @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} 503 - { error } when Atlas API is not configured
* @returns {Object} 500 - { error } on database failure * @returns {Object} 500 - { error } on database failure
*/ */
router.get('/status', requireAuth(), async (req, res) => { router.get('/status', async (req, res) => {
if (!isConfigured) { 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.' }); 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 { try {
const teamsParam = req.query.teams;
let rows; let rows;
if (teamsParam) { if (req.teamScope) {
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); // Non-admin: scope to user's team findings
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); const result = await pool.query(
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
`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
FROM atlas_action_plans_cache a INNER JOIN (
INNER JOIN ( SELECT DISTINCT host_id FROM ivanti_findings
SELECT DISTINCT host_id FROM ivanti_findings WHERE bu_ownership ILIKE ANY($1::text[])
WHERE bu_ownership ILIKE ANY($1::text[]) ) f ON a.host_id = f.host_id`,
) f ON a.host_id = f.host_id`, [patterns]
[patterns] );
); rows = result.rows;
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;
}
} else { } else {
// Admin bypass — all cached entries
const result = await pool.query( const result = await pool.query(
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache` `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. * 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. * 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. * 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} 200 - { synced, withPlans, failed }
* @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 503 - { error } when Atlas API is not configured
* @returns {Object} 500 - { error } on unexpected failure * @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) { 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.' }); 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 { try {
// Scope sync to the user's active teams if provided, otherwise sync only // Use team scope from middleware, fall back to managed BUs for admin
// 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') const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
.split(',').map(b => b.trim()).filter(Boolean); .split(',').map(b => b.trim()).filter(Boolean);
let findingsRows; let patterns;
if (teamsParam) { if (req.teamScope) {
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); patterns = req.teamScope.ivanti.map(t => `%${t}%`);
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 { } else {
// No teams specified — default to managed BUs only // Admin with no specific scope — sync managed BUs
const patterns = managedBUs.map(b => `%${b}%`); 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 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); const hostIds = findingsRows.map(r => r.host_id);
if (hostIds.length === 0) { if (hostIds.length === 0) {

View File

@@ -1,7 +1,7 @@
// Ivanti Archive Routes — list, stats, and transition history for archived findings // Ivanti Archive Routes — list, stats, and transition history for archived findings
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth } = require('../middleware/auth'); const { requireAuth, requireTeam } = require('../middleware/auth');
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
@@ -30,18 +30,18 @@ function findRelatedActive(archive, activeFindings) {
function createIvantiArchiveRouter() { function createIvantiArchiveRouter() {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication and team scoping
router.use(requireAuth()); router.use(requireAuth());
router.use(requireTeam());
/** /**
* GET / * 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. * @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=ACTIVE, returns live open findings from ivanti_findings instead of archives.
* When state=CLOSED, includes both CLOSED and CLOSED_GONE records. * 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 * @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 } * { 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 } * { error: string }
*/ */
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { state, teams } = req.query; const { state } = req.query;
if (state && !VALID_STATES.includes(state)) { if (state && !VALID_STATES.includes(state)) {
return res.status(400).json({ return res.status(400).json({
@@ -59,9 +59,9 @@ function createIvantiArchiveRouter() {
}); });
} }
// Parse teams filter into ILIKE patterns // Build team patterns from middleware (null = admin, no filter)
const teamPatterns = teams const teamPatterns = req.teamScope
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') ? req.teamScope.ivanti.map(t => `%${t}%`)
: []; : [];
try { try {
@@ -148,9 +148,7 @@ function createIvantiArchiveRouter() {
/** /**
* GET /stats * GET /stats
* Summary counts of archive records grouped by lifecycle state. * Summary counts of archive records grouped by lifecycle state.
* * Team scoping enforced by requireTeam() middleware via req.teamScope.
* @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.
* *
* @response {object} 200 * @response {object} 200
* { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number } * { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
@@ -159,9 +157,9 @@ function createIvantiArchiveRouter() {
*/ */
router.get('/stats', async (req, res) => { router.get('/stats', async (req, res) => {
try { try {
const { teams } = req.query; // Build team patterns from middleware (null = admin, no filter)
const teamPatterns = teams const teamPatterns = req.teamScope
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') ? req.teamScope.ivanti.map(t => `%${t}%`)
: []; : [];
let archiveQuery, archiveParams = []; 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 = []; let activeQuery, activeParams = [];
if (teamPatterns.length > 0) { if (teamPatterns.length > 0) {
activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`; activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`;