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.' }); }