diff --git a/backend/__tests__/compliance-duplicate-chart-entries.property.test.js b/backend/__tests__/compliance-duplicate-chart-entries.property.test.js index a0280a2..671cea4 100644 --- a/backend/__tests__/compliance-duplicate-chart-entries.property.test.js +++ b/backend/__tests__/compliance-duplicate-chart-entries.property.test.js @@ -36,6 +36,7 @@ const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js b/backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js index 8ba0f4d..5d7df1a 100644 --- a/backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js +++ b/backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js @@ -40,6 +40,7 @@ const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js b/backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js index 210ede3..cf3e997 100644 --- a/backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js +++ b/backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js @@ -26,6 +26,7 @@ const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/compliance-per-metric-metadata.test.js b/backend/__tests__/compliance-per-metric-metadata.test.js index cb926af..bd8a3c9 100644 --- a/backend/__tests__/compliance-per-metric-metadata.test.js +++ b/backend/__tests__/compliance-per-metric-metadata.test.js @@ -16,6 +16,7 @@ const express = require('express'); // Mock auth middleware jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js b/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js index 7dddc95..dcd4137 100644 --- a/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js +++ b/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js @@ -20,6 +20,7 @@ const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => next(), requireGroup: () => (req, res, next) => next(), })); diff --git a/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js b/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js index 958a931..ef5bc9f 100644 --- a/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js +++ b/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js @@ -22,6 +22,7 @@ const fc = require('fast-check'); // Mock dependencies required by the compliance module jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => next(), requireGroup: () => (req, res, next) => next(), })); diff --git a/backend/__tests__/fp-submissions-cleanup.test.js b/backend/__tests__/fp-submissions-cleanup.test.js index 8e52d27..507e63b 100644 --- a/backend/__tests__/fp-submissions-cleanup.test.js +++ b/backend/__tests__/fp-submissions-cleanup.test.js @@ -14,6 +14,7 @@ const express = require('express'); // Mock auth middleware to bypass real session checks jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js b/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js index 208f4ed..1100ae7 100644 --- a/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js +++ b/backend/__tests__/ivanti-queue-clear-completed-fix.property.test.js @@ -35,6 +35,7 @@ const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, _res, next) => { req.user = { id: 42, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/ivanti-queue-clear-completed-fix.test.js b/backend/__tests__/ivanti-queue-clear-completed-fix.test.js index a022b95..92c2840 100644 --- a/backend/__tests__/ivanti-queue-clear-completed-fix.test.js +++ b/backend/__tests__/ivanti-queue-clear-completed-fix.test.js @@ -18,6 +18,7 @@ const express = require('express'); // --- Mocks --- jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, _res, next) => { req.user = { id: 7, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/ivanti-todo-queue-ticket-links.test.js b/backend/__tests__/ivanti-todo-queue-ticket-links.test.js index bfcc006..456e58b 100644 --- a/backend/__tests__/ivanti-todo-queue-ticket-links.test.js +++ b/backend/__tests__/ivanti-todo-queue-ticket-links.test.js @@ -7,6 +7,7 @@ const express = require('express'); // Mock auth middleware jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, _res, next) => { req.user = { id: 7, username: 'testuser' }; next(); diff --git a/backend/__tests__/jira-route-removal.test.js b/backend/__tests__/jira-route-removal.test.js index 119568c..5c0fdae 100644 --- a/backend/__tests__/jira-route-removal.test.js +++ b/backend/__tests__/jira-route-removal.test.js @@ -18,6 +18,7 @@ const express = require('express'); // Mock the auth middleware so routes don't require real sessions/cookies. jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'test', group: 'Admin' }; next(); diff --git a/backend/__tests__/jira-ticket-queue-items.test.js b/backend/__tests__/jira-ticket-queue-items.test.js index e750217..e81dfe4 100644 --- a/backend/__tests__/jira-ticket-queue-items.test.js +++ b/backend/__tests__/jira-ticket-queue-items.test.js @@ -12,6 +12,7 @@ const express = require('express'); // Mock the auth middleware so routes don't require real sessions/cookies. jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'test', group: 'Admin' }; next(); diff --git a/backend/__tests__/vcl-aggregated-burndown.test.js b/backend/__tests__/vcl-aggregated-burndown.test.js index d4a526e..6e60038 100644 --- a/backend/__tests__/vcl-aggregated-burndown.test.js +++ b/backend/__tests__/vcl-aggregated-burndown.test.js @@ -17,6 +17,7 @@ const express = require('express'); // Mock auth middleware jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); diff --git a/backend/__tests__/vcl-compliance-reporting.test.js b/backend/__tests__/vcl-compliance-reporting.test.js index 93108b1..d1aab60 100644 --- a/backend/__tests__/vcl-compliance-reporting.test.js +++ b/backend/__tests__/vcl-compliance-reporting.test.js @@ -17,6 +17,7 @@ const express = require('express'); // Mock auth middleware to bypass real session checks jest.mock('../middleware/auth', () => ({ + requireTeam: () => (req, res, next) => { req.teamScope = null; next(); }, requireAuth: () => (req, res, next) => { req.user = { id: 1, username: 'testuser', group: 'Admin' }; next(); 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/helpers/teams.js b/backend/helpers/teams.js index 306eed7..06fc217 100644 --- a/backend/helpers/teams.js +++ b/backend/helpers/teams.js @@ -1,8 +1,41 @@ -// Shared BU team constants and validation +// Shared BU team constants, validation, and name mapping. // Used by user management routes, auth middleware, and frontend-facing endpoints. const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; +// Mapping between short team names (stored on users) and full Ivanti BU identifiers +// (used in ivanti_findings.bu_ownership column). +const TEAM_TO_IVANTI = { + 'STEAM': 'NTS-AEO-STEAM', + 'ACCESS-ENG': 'NTS-AEO-ACCESS-ENG', + 'ACCESS-OPS': 'NTS-AEO-ACCESS-OPS', + 'INTELDEV': 'NTS-AEO-INTELDEV' +}; + +const IVANTI_TO_TEAM = Object.fromEntries( + Object.entries(TEAM_TO_IVANTI).map(([k, v]) => [v, k]) +); + +/** + * Convert a short team name to the full Ivanti BU identifier. + * Returns the input unchanged if no mapping exists. + * @param {string} shortName - e.g. 'STEAM' + * @returns {string} e.g. 'NTS-AEO-STEAM' + */ +function teamToIvanti(shortName) { + return TEAM_TO_IVANTI[shortName] || shortName; +} + +/** + * Convert a full Ivanti BU identifier to the short team name. + * Returns the input unchanged if no mapping exists. + * @param {string} ivantiName - e.g. 'NTS-AEO-STEAM' + * @returns {string} e.g. 'STEAM' + */ +function ivantiToTeam(ivantiName) { + return IVANTI_TO_TEAM[ivantiName] || ivantiName; +} + /** * Parse and validate a comma-separated teams string. * @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG') @@ -23,4 +56,4 @@ function validateTeams(teamsString) { }; } -module.exports = { KNOWN_TEAMS, validateTeams }; +module.exports = { KNOWN_TEAMS, TEAM_TO_IVANTI, IVANTI_TO_TEAM, teamToIvanti, ivantiToTeam, validateTeams }; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 9d9f3eb..1bc1dcc 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,5 +1,6 @@ // Authentication Middleware const pool = require('../db'); +const { teamToIvanti } = require('../helpers/teams'); // Require authenticated user — no parameters needed, pool is imported directly function requireAuth() { @@ -12,7 +13,8 @@ function requireAuth() { try { const { rows } = await pool.query( - `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active + `SELECT s.*, s.impersonate_user_id, + u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_id = $1 AND s.expires_at > NOW()`, @@ -29,8 +31,8 @@ function requireAuth() { return res.status(401).json({ error: 'Account is disabled' }); } - // Attach user to request - req.user = { + // Store the real admin identity (always the session owner) + req.realUser = { id: session.user_id, username: session.username, email: session.email, @@ -39,6 +41,35 @@ function requireAuth() { teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : [] }; + // If impersonating, load the target user's identity + if (session.impersonate_user_id) { + const { rows: targetRows } = await pool.query( + `SELECT id, username, email, role, user_group, bu_teams, is_active FROM users WHERE id = $1`, + [session.impersonate_user_id] + ); + const target = targetRows[0]; + + if (target && target.is_active) { + req.user = { + id: target.id, + username: target.username, + email: target.email, + role: target.role, + group: target.user_group, + teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : [] + }; + req.impersonating = true; + } else { + // Target user no longer valid — clear impersonation and use real user + await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]); + req.user = req.realUser; + req.impersonating = false; + } + } else { + req.user = req.realUser; + req.impersonating = false; + } + next(); } catch (err) { console.error('Auth middleware error:', err); @@ -66,4 +97,38 @@ function requireGroup(...allowedGroups) { }; } -module.exports = { requireAuth, requireGroup }; +// Require team assignment — enforces team-scoped data access. +// Admin group bypasses (req.teamScope = null means "no filter"). +// Non-admin users without teams get 403. +// Non-admin users with teams get req.teamScope = { short: [...], ivanti: [...] }. +function requireTeam() { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + // Admin bypass — full access to all teams + if (req.user.group === 'Admin') { + req.teamScope = null; + return next(); + } + + // No teams assigned — block access + if (!req.user.teams || req.user.teams.length === 0) { + return res.status(403).json({ + error: 'No team assignment. Contact an administrator to assign BU teams to your account.', + code: 'NO_TEAM_ASSIGNMENT' + }); + } + + // Build scope with both naming conventions + req.teamScope = { + short: req.user.teams, + ivanti: req.user.teams.map(t => teamToIvanti(t)) + }; + + next(); + }; +} + +module.exports = { requireAuth, requireGroup, requireTeam }; diff --git a/backend/migrations/add_session_impersonation.js b/backend/migrations/add_session_impersonation.js new file mode 100644 index 0000000..fbd5716 --- /dev/null +++ b/backend/migrations/add_session_impersonation.js @@ -0,0 +1,26 @@ +// Migration: Add impersonate_user_id column to sessions table +// Allows Admin users to temporarily view the app as another user. +// When set, requireAuth() overrides req.user with the target user's identity. + +const pool = require('../db'); + +async function run() { + console.log('[Migration] add_session_impersonation: starting...'); + + // Add impersonate_user_id column (nullable FK to users) + await pool.query(` + ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL + `); + + console.log('[Migration] add_session_impersonation: column added.'); + console.log('[Migration] add_session_impersonation: done.'); + await pool.end(); +} + +// Run directly if invoked as a script +if (require.main === module) { + run().catch(err => { console.error(err); process.exit(1); }); +} + +module.exports = run; diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 2d8cdf5..7a6efe9 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -33,6 +33,7 @@ const POSTGRES_MIGRATIONS = [ 'add_ivanti_findings_ipv6_columns.js', 'add_user_ivanti_identity.js', 'add_atlas_known_column.js', + 'add_session_impersonation.js', ]; async function runAll() { diff --git a/backend/routes/archerTemplates.js b/backend/routes/archerTemplates.js index db47f49..0e98d5a 100644 --- a/backend/routes/archerTemplates.js +++ b/backend/routes/archerTemplates.js @@ -20,6 +20,10 @@ const SECTION_MAX_LENGTH = 10000; function createArcherTemplatesRouter() { const router = express.Router(); + // All Archer template routes require authentication and Admin or Standard_User group (page-level access) + router.use(requireAuth()); + router.use(requireGroup('Admin', 'Standard_User')); + // --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) --- /** 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/auth.js b/backend/routes/auth.js index e243150..be75d17 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -195,9 +195,10 @@ function createAuthRouter(logAudit) { * GET /api/auth/me * * Returns the currently authenticated user based on the session cookie. - * Clears the cookie and returns 401 if the session is expired or the account is disabled. + * If impersonating, returns the impersonated user's identity with an + * `impersonating` flag and the real admin user's info. * - * @returns {object} 200 - { user: { id, username, email, group } } + * @returns {object} 200 - { user: { id, username, email, group, teams }, impersonating?: boolean, realUser?: { id, username, group } } * @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' } * @returns {object} 500 - { error: 'Failed to get user' } */ @@ -210,7 +211,8 @@ function createAuthRouter(logAudit) { try { const { rows } = await pool.query( - `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active + `SELECT s.*, s.impersonate_user_id, + u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_id = $1 AND s.expires_at > NOW()`, @@ -229,6 +231,36 @@ function createAuthRouter(logAudit) { return res.status(401).json({ error: 'Account is disabled' }); } + // If impersonating, return target user's identity + if (session.impersonate_user_id) { + const { rows: targetRows } = await pool.query( + `SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`, + [session.impersonate_user_id] + ); + const target = targetRows[0]; + + if (target && target.is_active) { + return res.json({ + user: { + id: target.id, + username: target.username, + email: target.email, + group: target.user_group, + teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : [] + }, + impersonating: true, + realUser: { + id: session.user_id, + username: session.username, + group: session.user_group + } + }); + } else { + // Target invalid — clear impersonation + await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]); + } + } + res.json({ user: { id: session.user_id, @@ -244,6 +276,133 @@ function createAuthRouter(logAudit) { } }); + /** + * POST /api/auth/impersonate + * + * Start impersonating another user. Only Admin group can impersonate. + * Cannot impersonate another Admin user. + * + * @body {number} userId - The ID of the user to impersonate + * @returns {object} 200 - { message, user: { id, username, group, teams } } + * @returns {object} 400 - { error } — cannot impersonate Admin or self + * @returns {object} 403 - { error } — not Admin + * @returns {object} 404 - { error } — target user not found + * @returns {object} 500 - { error } + */ + router.post('/impersonate', requireAuth(), async (req, res) => { + // Only the real user (not an impersonated identity) can start impersonation + const realUser = req.realUser || req.user; + + if (realUser.group !== 'Admin') { + return res.status(403).json({ error: 'Only Admin users can impersonate.' }); + } + + const { userId } = req.body; + if (!userId || typeof userId !== 'number') { + return res.status(400).json({ error: 'userId is required and must be a number.' }); + } + + if (userId === realUser.id) { + return res.status(400).json({ error: 'Cannot impersonate yourself.' }); + } + + try { + const { rows } = await pool.query( + `SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`, + [userId] + ); + const target = rows[0]; + + if (!target) { + return res.status(404).json({ error: 'User not found.' }); + } + + if (!target.is_active) { + return res.status(400).json({ error: 'Cannot impersonate a disabled account.' }); + } + + if (target.user_group === 'Admin') { + return res.status(400).json({ error: 'Cannot impersonate another Admin user.' }); + } + + // Set impersonation on the session + const sessionId = req.cookies?.session_id; + await pool.query( + `UPDATE sessions SET impersonate_user_id = $1 WHERE session_id = $2`, + [userId, sessionId] + ); + + logAudit({ + userId: realUser.id, + username: realUser.username, + action: 'impersonate_start', + entityType: 'user', + entityId: String(userId), + details: { target_username: target.username, target_group: target.user_group }, + ipAddress: req.ip + }); + + res.json({ + message: `Now viewing as ${target.username}`, + user: { + id: target.id, + username: target.username, + email: target.email, + group: target.user_group, + teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : [] + } + }); + } catch (err) { + console.error('Impersonate error:', err); + res.status(500).json({ error: 'Failed to start impersonation.' }); + } + }); + + /** + * POST /api/auth/stop-impersonate + * + * Stop impersonating and revert to the real Admin identity. + * + * @returns {object} 200 - { message, user: { id, username, group, teams } } + * @returns {object} 500 - { error } + */ + router.post('/stop-impersonate', requireAuth(), async (req, res) => { + const sessionId = req.cookies?.session_id; + + try { + await pool.query( + `UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, + [sessionId] + ); + + const realUser = req.realUser || req.user; + + logAudit({ + userId: realUser.id, + username: realUser.username, + action: 'impersonate_stop', + entityType: 'user', + entityId: null, + details: null, + ipAddress: req.ip + }); + + res.json({ + message: 'Impersonation ended', + user: { + id: realUser.id, + username: realUser.username, + email: realUser.email, + group: realUser.group, + teams: realUser.teams + } + }); + } catch (err) { + console.error('Stop impersonate error:', err); + res.status(500).json({ error: 'Failed to stop impersonation.' }); + } + }); + /** * GET /api/auth/profile * diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index 53ba753..e630e0e 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.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, @@ -112,7 +112,7 @@ function createCardApiRouter() { * @response 400 - { error: string } — missing disposition * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ - router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), requireTeam(), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } @@ -120,6 +120,16 @@ function createCardApiRouter() { const { teamName } = req.params; const { disposition, page, page_size } = req.query; + // Validate requested team is in user's scope + if (req.teamScope && !req.teamScope.short.includes(teamName)) { + return res.status(403).json({ + error: 'Access denied. You do not have access to the requested team.', + code: 'TEAM_ACCESS_DENIED', + requested: teamName, + allowed: req.teamScope.short + }); + } + if (!disposition) { return res.status(400).json({ error: 'disposition query parameter is required.' }); } diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 2791782..968d988 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -7,7 +7,7 @@ const fs = require('fs'); const crypto = require('crypto'); const { spawn } = require('child_process'); const pool = require('../db'); -const { requireAuth, requireGroup } = require('../middleware/auth'); +const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth'); const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker'); const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers'); const logAudit = require('../helpers/auditLog'); @@ -288,8 +288,9 @@ function computeWaterfall(uploads) { function createComplianceRouter(upload) { const router = express.Router(); - // All compliance routes require authentication + // All compliance routes require authentication and team assignment router.use(requireAuth()); + router.use(requireTeam()); /** * POST /preview @@ -537,6 +538,16 @@ function createComplianceRouter(upload) { const team = req.query.team; if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); + // Validate requested team is in user's scope + if (team && req.teamScope && !req.teamScope.short.includes(team)) { + return res.status(403).json({ + error: 'Access denied. You do not have access to the requested team.', + code: 'TEAM_ACCESS_DENIED', + requested: team, + allowed: req.teamScope.short + }); + } + try { // Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload let { rows: latestRows } = await pool.query( @@ -600,6 +611,16 @@ function createComplianceRouter(upload) { if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' }); + // Validate requested team is in user's scope + if (req.teamScope && !req.teamScope.short.includes(team)) { + return res.status(403).json({ + error: 'Access denied. You do not have access to the requested team.', + code: 'TEAM_ACCESS_DENIED', + requested: team, + allowed: req.teamScope.short + }); + } + try { // Include items from both AEO uploads (vertical IS NULL) and NTS_AEO multi-vertical uploads // DISTINCT ON deduplicates cross-vertical (hostname, metric_id) pairs, keeping the representative row 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[])`; diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 2e0576d..a4c2db7 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -4,7 +4,7 @@ // Daily auto-sync fetches from Ivanti API and upserts rows. const express = require('express'); -const { requireGroup } = require('../middleware/auth'); +const { requireGroup, requireTeam } = require('../middleware/auth'); const { ivantiPost } = require('../helpers/ivantiApi'); const pool = require('../db'); @@ -23,8 +23,10 @@ function formatDate(val) { return String(val).slice(0, 10); } -// Configurable BU filter — broadened via env var to support multi-tenancy. -// Users see only their assigned teams' findings (filtered at query time). +// Configurable BU filter — controls sync-level filtering (what gets pulled from Ivanti API). +// Per-user query-time filtering is handled by requireTeam() middleware, which scopes +// API responses to the user's assigned teams. This env var determines the superset of +// data that exists in the local database. const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM'; const FINDINGS_FILTERS = [ @@ -1079,32 +1081,29 @@ function createIvantiFindingsRouter(db, requireAuth) { scheduleSync(); router.use(requireAuth()); + router.use(requireTeam()); /** * GET /api/ivanti/findings * * Return findings from ivanti_findings table (state='open') with notes and overrides. - * Accepts optional `teams` query parameter (comma-separated) to filter - * findings by buOwnership. If omitted, returns all open findings. + * Team scoping is enforced by requireTeam() middleware via req.teamScope. + * Admin users see all findings; non-admin users see only their assigned teams. * - * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { findings, total, synced_at, sync_status, error_message } * @returns {Object} 500 - { error: string } on database error */ router.get('/', async (req, res) => { try { - const teamsParam = req.query.teams; let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`; const params = []; let paramIndex = 1; - if (teamsParam) { - const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); - if (teams.length > 0) { - const patterns = teams.map(t => `%${t}%`); - query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; - params.push(patterns); - } + // Team scoping (null = admin bypass, no filter) + if (req.teamScope) { + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(patterns); } query += ' ORDER BY severity DESC'; @@ -1166,10 +1165,19 @@ function createIvantiFindingsRouter(db, requireAuth) { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { await syncFindings(); try { - // Return fresh state after sync - const { rows } = await pool.query( - `SELECT * FROM ivanti_findings WHERE state = 'open' ORDER BY severity DESC` - ); + // Return fresh state after sync, scoped to user's teams + let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`; + const params = []; + let paramIndex = 1; + + if (req.teamScope) { + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(patterns); + } + + query += ' ORDER BY severity DESC'; + const { rows } = await pool.query(query, params); const findings = rows.map(row => ({ id: row.id, hostId: row.host_id, @@ -1215,27 +1223,22 @@ function createIvantiFindingsRouter(db, requireAuth) { * GET /api/ivanti/findings/counts * * Return open vs closed finding totals. - * Accepts optional `teams` query parameter to scope counts to specific BUs. - * With Postgres, both open AND closed counts are per-BU when filtered. + * Team scoping is enforced by requireTeam() middleware via req.teamScope. * - * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { open: number, closed: number, filtered: boolean } * @returns {Object} 500 - { error: string } on database error */ router.get('/counts', async (req, res) => { try { - const teamsParam = req.query.teams; let whereExtra = ''; const params = []; let paramIndex = 1; - if (teamsParam) { - const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); - if (teams.length > 0) { - const patterns = teams.map(t => `%${t}%`); - whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; - params.push(patterns); - } + // Team scoping (null = admin bypass, no filter) + if (req.teamScope) { + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(patterns); } const { rows } = await pool.query( @@ -1246,7 +1249,7 @@ function createIvantiFindingsRouter(db, requireAuth) { const counts = { open: 0, closed: 0 }; rows.forEach(r => { counts[r.state] = parseInt(r.count); }); - res.json({ ...counts, filtered: !!teamsParam }); + res.json({ ...counts, filtered: !!req.teamScope }); } catch (err) { console.error('[Ivanti Findings] GET /counts error:', err.message); res.status(500).json({ error: 'Database error reading counts' }); @@ -1257,45 +1260,38 @@ function createIvantiFindingsRouter(db, requireAuth) { * GET /api/ivanti/findings/counts/history * * Return the last snapshot per day (ascending) for the trend chart. - * Accepts optional `teams` query parameter to scope the trend to specific BUs. - * When teams is provided, uses the per-BU history table. - * When no teams, returns the global aggregate history. + * Team scoping is enforced by requireTeam() middleware via req.teamScope. + * When scoped, uses the per-BU history table. When admin (no scope), returns global aggregate. * - * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> } * @returns {Object} 500 - { error: string } on database error */ router.get('/counts/history', async (req, res) => { try { - const teamsParam = req.query.teams; - - if (teamsParam) { - // Per-BU history — filter and aggregate by selected teams - const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); - if (teams.length > 0) { - const patterns = teams.map(t => `%${t}%`); - const { rows } = await pool.query( - `SELECT date, - SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count, - SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count - FROM ( - SELECT recorded_at::date AS date, bu_ownership, state, count, - ROW_NUMBER() OVER ( - PARTITION BY recorded_at::date, bu_ownership, state - ORDER BY recorded_at DESC - ) AS rn - FROM ivanti_counts_history_by_bu - WHERE bu_ownership ILIKE ANY($1::text[]) - ) sub WHERE rn = 1 - GROUP BY date - ORDER BY date ASC`, - [patterns] - ); - return res.json({ history: rows }); - } + if (req.teamScope) { + // Per-BU history — filter and aggregate by user's assigned teams + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + const { rows } = await pool.query( + `SELECT date, + SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count, + SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count + FROM ( + SELECT recorded_at::date AS date, bu_ownership, state, count, + ROW_NUMBER() OVER ( + PARTITION BY recorded_at::date, bu_ownership, state + ORDER BY recorded_at DESC + ) AS rn + FROM ivanti_counts_history_by_bu + WHERE bu_ownership ILIKE ANY($1::text[]) + ) sub WHERE rn = 1 + GROUP BY date + ORDER BY date ASC`, + [patterns] + ); + return res.json({ history: rows }); } - // Global history (no filter) + // Global history (admin — no filter) const { rows } = await pool.query( `SELECT date, open_count, closed_count FROM ( SELECT recorded_at::date AS date, @@ -1320,26 +1316,22 @@ function createIvantiFindingsRouter(db, requireAuth) { * * Return FP finding counts and unique workflow ID counts (open + closed), * broken down by workflow status. - * Accepts optional `teams` query parameter to scope to specific BUs. + * Team scoping is enforced by requireTeam() middleware via req.teamScope. * - * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') * @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number } * @returns {Object} 500 - { error: string } on database error */ router.get('/fp-workflow-counts', async (req, res) => { try { - const teamsParam = req.query.teams; let whereExtra = ''; const params = []; let paramIndex = 1; - if (teamsParam) { - const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); - if (teams.length > 0) { - const patterns = teams.map(t => `%${t}%`); - whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; - params.push(patterns); - } + // Team scoping (null = admin bypass, no filter) + if (req.teamScope) { + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(patterns); } // Finding counts: number of findings per workflow state @@ -1557,19 +1549,35 @@ function createIvantiFindingsRouter(db, requireAuth) { * PUT /api/ivanti/findings/:findingId/override * * Save or clear field overrides for a finding. Requires Admin or Standard_User group. - * Accepts hostName and/or dns in the body. Empty/null values clear the override. + * Team scoping enforced — user can only override findings in their team scope. * * @param {string} findingId - The finding identifier (URL param) * @body {string} [hostName] - Override for host name; empty/null to clear * @body {string} [dns] - Override for DNS; empty/null to clear * * @returns {Object} 200 - { finding_id, overrides: { hostName, dns } } + * @returns {Object} 403 - { error: string } when finding is outside team scope * @returns {Object} 404 - { error: string } when finding not found * @returns {Object} 500 - { error: string } on database error */ router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => { try { const { findingId } = req.params; + + // Verify finding is in user's team scope + if (req.teamScope) { + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + const check = await pool.query( + `SELECT id FROM ivanti_findings WHERE id = $1 AND bu_ownership ILIKE ANY($2::text[])`, + [findingId, patterns] + ); + if (check.rows.length === 0) { + return res.status(403).json({ + error: 'Access denied. This finding is outside your team scope.', + code: 'TEAM_ACCESS_DENIED' + }); + } + } const { hostName, dns, field, value } = req.body; // Support legacy single-field format: { field: 'hostName', value: 'x' } @@ -1639,16 +1647,34 @@ function createIvantiFindingsRouter(db, requireAuth) { * * Save or update a note for a finding (max 255 characters). * Requires Admin or Standard_User group. + * Team scoping enforced — user can only note findings in their team scope. * * @param {string} findingId - The finding identifier (URL param) * @body {string} [note] - The note text (truncated to 255 chars) * * @returns {Object} 200 - { finding_id: string, note: string } + * @returns {Object} 403 - { error: string } when finding is outside team scope * @returns {Object} 500 - { error: string } on database error */ router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), async (req, res) => { try { const { findingId } = req.params; + + // Verify finding is in user's team scope + if (req.teamScope) { + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + const check = await pool.query( + `SELECT id FROM ivanti_findings WHERE id = $1 AND bu_ownership ILIKE ANY($2::text[])`, + [findingId, patterns] + ); + if (check.rows.length === 0) { + return res.status(403).json({ + error: 'Access denied. This finding is outside your team scope.', + code: 'TEAM_ACCESS_DENIED' + }); + } + } + const note = String(req.body.note || '').slice(0, 255); await pool.query( diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 968b994..d71deac 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -1,7 +1,7 @@ // routes/ivantiTodoQueue.js 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 VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate']; @@ -88,7 +88,7 @@ function createIvantiTodoQueueRouter() { * @error 400 Invalid input * @error 500 Internal server error */ - router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), requireTeam(), async (req, res) => { const { findings, workflow_type, vendor } = req.body; if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { @@ -102,6 +102,25 @@ function createIvantiTodoQueueRouter() { } } + // Verify findings belong to user's team scope (skip for admin) + if (req.teamScope) { + const findingIds = findings.map(f => f.finding_id.trim()); + const patterns = req.teamScope.ivanti.map(t => `%${t}%`); + const { rows: validFindings } = await pool.query( + `SELECT id FROM ivanti_findings WHERE id = ANY($1) AND bu_ownership ILIKE ANY($2::text[])`, + [findingIds, patterns] + ); + const validIds = new Set(validFindings.map(r => String(r.id))); + const blocked = findingIds.filter(id => !validIds.has(id)); + if (blocked.length > 0) { + return res.status(403).json({ + error: 'Some findings are outside your team scope and cannot be added to your queue.', + code: 'TEAM_ACCESS_DENIED', + blocked_findings: blocked + }); + } + } + if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); } diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js index e5b3c63..d73a5c0 100644 --- a/backend/routes/jiraTickets.js +++ b/backend/routes/jiraTickets.js @@ -30,6 +30,10 @@ function isValidVendor(vendor) { function createJiraTicketsRouter() { const router = express.Router(); + // All Jira routes require authentication and Admin or Standard_User group (page-level access) + router.use(requireAuth()); + router.use(requireGroup('Admin', 'Standard_User')); + // ----------------------------------------------------------------------- // Jira API integration endpoints // ----------------------------------------------------------------------- diff --git a/backend/server.js b/backend/server.js index 6be7b7d..1bf59ff 100644 --- a/backend/server.js +++ b/backend/server.js @@ -161,13 +161,18 @@ app.use('/api/audit-logs', createAuditLogRouter()); app.get('/api/recent-activity', requireAuth(), async (req, res) => { try { const limit = Math.min(15, Math.max(1, parseInt(req.query.limit) || 10)); + // Hide impersonation events from non-Admin users + const excludedActions = ['login', 'logout', 'login_failed']; + if (req.user.group !== 'Admin') { + excludedActions.push('impersonate_start', 'impersonate_stop'); + } const { rows } = await pool.query( `SELECT username, action, entity_type, entity_id, details, created_at FROM audit_logs - WHERE action NOT IN ('login', 'logout', 'login_failed') + WHERE action NOT IN (${excludedActions.map((_, i) => `$${i + 1}`).join(', ')}) ORDER BY created_at DESC - LIMIT $1`, - [limit] + LIMIT $${excludedActions.length + 1}`, + [...excludedActions, limit] ); res.json({ activities: rows }); } catch (err) { diff --git a/frontend/src/App.js b/frontend/src/App.js index 79b8af6..554302a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -8,6 +8,7 @@ import AuditLog from './components/AuditLog'; import NvdSyncModal from './components/NvdSyncModal'; import NavDrawer from './components/NavDrawer'; import AdminScopeToggle from './components/AdminScopeToggle'; +import ImpersonationBanner from './components/ImpersonationBanner'; import VulnerabilityTriagePage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; @@ -19,20 +20,20 @@ import ArcherTemplatePage from './components/pages/ArcherTemplatePage'; import HomePage from './components/pages/HomePage'; import FeedbackModal from './components/FeedbackModal'; import NotificationBell from './components/NotificationBell'; +import { canAccessPage } from './config/pageVisibility'; import './App.css'; -const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']); - export default function App() { - const { isAuthenticated, loading: authLoading, canWrite, isAdmin, isInGroup } = useAuth(); + const { isAuthenticated, loading: authLoading, canWrite, user } = useAuth(); const [currentPage, setCurrentPageRaw] = useState(() => { try { const saved = localStorage.getItem('cve-dashboard-page'); - return saved && VALID_PAGES.has(saved) ? saved : 'home'; + return saved && canAccessPage(saved, user?.group) ? saved : 'home'; } catch { return 'home'; } }); const setCurrentPage = (page) => { + if (!canAccessPage(page, user?.group)) { setCurrentPageRaw('home'); return; } setCurrentPageRaw(page); try { localStorage.setItem('cve-dashboard-page', page); } catch {} }; @@ -75,6 +76,7 @@ export default function App() { return (
+ setNavOpen(false)} @@ -160,18 +162,16 @@ export default function App() {
- {/* Page content */} + {/* Page content — generic route guard via canAccessPage */} {currentPage === 'home' && } {currentPage === 'triage' && } {currentPage === 'compliance' && } - {currentPage === 'ccp-metrics' && isInGroup('Admin', 'Leadership') && } - {currentPage === 'ccp-metrics' && !isInGroup('Admin', 'Leadership') && (() => { setCurrentPage('home'); return null; })()} + {currentPage === 'ccp-metrics' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } {currentPage === 'jira' && } {currentPage === 'archer-templates' && } - {currentPage === 'admin' && isAdmin() && } - {currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()} + {currentPage === 'admin' && } {/* Global Modals */} {showUserManagement && setShowUserManagement(false)} />} diff --git a/frontend/src/components/ImpersonationBanner.js b/frontend/src/components/ImpersonationBanner.js new file mode 100644 index 0000000..1c8c8fd --- /dev/null +++ b/frontend/src/components/ImpersonationBanner.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { Eye, X } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; + +/** + * ImpersonationBanner — renders a fixed banner at the top of the viewport + * when an Admin is viewing the app as another user. Shows who is being + * impersonated and provides a button to exit. + */ +export default function ImpersonationBanner() { + const { impersonating, user, realUser, stopImpersonation } = useAuth(); + + if (!impersonating) return null; + + const handleStop = async () => { + await stopImpersonation(); + // Force page reload to reset all state to admin's view + window.location.reload(); + }; + + return ( +
+ + + Viewing as: {user?.username} ({user?.group}, teams: {user?.teams?.join(', ') || 'none'}) + {realUser && — logged in as {realUser.username}} + + +
+ ); +} diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 2807bb1..82f9839 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -1,12 +1,13 @@ import React from 'react'; import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { canAccessPage } from '../config/pageVisibility'; const NAV_ITEMS = [ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, { id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' }, { id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' }, - { id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting', requiredGroups: ['Admin', 'Leadership'] }, + { id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' }, { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, { id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' }, @@ -16,7 +17,7 @@ const NAV_ITEMS = [ const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' }; export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) { - const { isAdmin, isInGroup } = useAuth(); + const { user } = useAuth(); if (!isOpen) return null; @@ -70,7 +71,7 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {/* Nav items */}