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,
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);

View File

@@ -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,31 +70,31 @@ 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}%`);
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
@@ -107,12 +107,7 @@ function createAtlasRouter() {
);
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 {
// 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,27 +126,23 @@ 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}%`);
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
@@ -163,12 +154,7 @@ function createAtlasRouter() {
);
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 {
// 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,63 +173,39 @@ 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;
let patterns;
if (req.teamScope) {
patterns = req.teamScope.ivanti.map(t => `%${t}%`);
} else {
// No valid teams — fall back to managed BUs
const patterns = managedBUs.map(b => `%${b}%`);
// 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]
);
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 findingsRows = result.rows;
const hostIds = findingsRows.map(r => r.host_id);

View File

@@ -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[])`;