From a003091b6a80a139070a9d246ea9735ef52c9d6d Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 24 Jun 2026 11:36:25 -0600 Subject: [PATCH 1/5] Add backend team enforcement via requireTeam() middleware Introduce server-side team-scoped data access enforcement: - Add TEAM_TO_IVANTI/IVANTI_TO_TEAM mapping to helpers/teams.js - Add requireTeam() middleware to middleware/auth.js - Admin bypass (req.teamScope = null) - 403 for users with no team assignment - Populates req.teamScope with short and ivanti name arrays - Ivanti findings: replace client ?teams= param with req.teamScope filtering on GET /, /counts, /counts/history, /fp-workflow-counts, POST /sync - Override and note endpoints verify finding is in team scope - Compliance: add requireTeam() router-level, validate ?team= param against scope on GET /items and GET /summary - CARD: validate teamName param on GET /teams/:teamName/assets - Todo queue: verify findings belong to user's teams on POST /batch - Clarify IVANTI_BU_FILTER comment (sync-level vs query-time filtering) - Update 14 test files to include requireTeam in auth middleware mocks --- ...e-duplicate-chart-entries.property.test.js | 1 + ...iling-metrics.exploration.property.test.js | 1 + ...ling-metrics.preservation.property.test.js | 1 + .../compliance-per-metric-metadata.test.js | 1 + ...n-display-fix.exploration.property.test.js | 1 + ...-display-fix.preservation.property.test.js | 1 + .../__tests__/fp-submissions-cleanup.test.js | 1 + ...queue-clear-completed-fix.property.test.js | 1 + .../ivanti-queue-clear-completed-fix.test.js | 1 + .../ivanti-todo-queue-ticket-links.test.js | 1 + backend/__tests__/jira-route-removal.test.js | 1 + .../__tests__/jira-ticket-queue-items.test.js | 1 + .../__tests__/vcl-aggregated-burndown.test.js | 1 + .../vcl-compliance-reporting.test.js | 1 + backend/helpers/teams.js | 37 +++- backend/middleware/auth.js | 37 +++- backend/routes/cardApi.js | 14 +- backend/routes/compliance.js | 25 ++- backend/routes/ivantiFindings.js | 170 ++++++++++-------- backend/routes/ivantiTodoQueue.js | 23 ++- 20 files changed, 239 insertions(+), 81 deletions(-) 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/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..d788365 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() { @@ -66,4 +67,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/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/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.' }); } From 11d9fec3ec057abc395f06a7b059caed74cb5482 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 24 Jun 2026 11:41:50 -0600 Subject: [PATCH 2/5] Add page visibility by group with centralized matrix Introduce a Page Visibility Matrix that controls which pages each user group can access, enforced in both frontend and backend: Frontend: - Create frontend/src/config/pageVisibility.js with PAGE_VISIBILITY matrix and canAccessPage() / getAccessiblePages() helpers - NavDrawer: replace inline requiredGroups with canAccessPage() filter - App.js: replace per-page isInGroup()/isAdmin() checks with generic route guard in setCurrentPage; remove VALID_PAGES constant - localStorage validation: verify persisted page is accessible on load Backend (page-level access enforcement): - jiraTickets.js: add router-level requireGroup('Admin','Standard_User') - archerTemplates.js: add router-level requireGroup('Admin','Standard_User') - VCL multi-vertical already had requireGroup('Admin','Leadership') Visibility matrix: - Home, Knowledge Base: all groups - Triage, Compliance, Exports: Admin, Standard_User, Leadership - CCP Metrics: Admin, Leadership - Jira, Archer Templates: Admin, Standard_User - Admin Panel: Admin only - Read_Only sees only Home and Knowledge Base --- backend/routes/archerTemplates.js | 4 +++ backend/routes/jiraTickets.js | 4 +++ frontend/src/App.js | 16 +++++------- frontend/src/components/NavDrawer.js | 11 ++++---- frontend/src/config/pageVisibility.js | 37 +++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 frontend/src/config/pageVisibility.js 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/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/frontend/src/App.js b/frontend/src/App.js index 79b8af6..95a6e99 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -19,20 +19,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 {} }; @@ -160,18 +160,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/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 */}