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
This commit is contained in:
Jordan Ramos
2026-06-24 11:36:25 -06:00
parent ab66d7d813
commit a003091b6a
20 changed files with 239 additions and 81 deletions

View File

@@ -36,6 +36,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -40,6 +40,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -26,6 +26,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -16,6 +16,7 @@ const express = require('express');
// Mock auth middleware // Mock auth middleware
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -20,6 +20,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => next(), requireAuth: () => (req, res, next) => next(),
requireGroup: () => (req, res, next) => next(), requireGroup: () => (req, res, next) => next(),
})); }));

View File

@@ -22,6 +22,7 @@ const fc = require('fast-check');
// Mock dependencies required by the compliance module // Mock dependencies required by the compliance module
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => next(), requireAuth: () => (req, res, next) => next(),
requireGroup: () => (req, res, next) => next(), requireGroup: () => (req, res, next) => next(),
})); }));

View File

@@ -14,6 +14,7 @@ const express = require('express');
// Mock auth middleware to bypass real session checks // Mock auth middleware to bypass real session checks
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -35,6 +35,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, _res, next) => { requireAuth: () => (req, _res, next) => {
req.user = { id: 42, username: 'testuser', group: 'Admin' }; req.user = { id: 42, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -18,6 +18,7 @@ const express = require('express');
// --- Mocks --- // --- Mocks ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, _res, next) => { requireAuth: () => (req, _res, next) => {
req.user = { id: 7, username: 'testuser', group: 'Admin' }; req.user = { id: 7, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -7,6 +7,7 @@ const express = require('express');
// Mock auth middleware // Mock auth middleware
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, _res, next) => { requireAuth: () => (req, _res, next) => {
req.user = { id: 7, username: 'testuser' }; req.user = { id: 7, username: 'testuser' };
next(); next();

View File

@@ -18,6 +18,7 @@ const express = require('express');
// Mock the auth middleware so routes don't require real sessions/cookies. // Mock the auth middleware so routes don't require real sessions/cookies.
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'test', group: 'Admin' }; req.user = { id: 1, username: 'test', group: 'Admin' };
next(); next();

View File

@@ -12,6 +12,7 @@ const express = require('express');
// Mock the auth middleware so routes don't require real sessions/cookies. // Mock the auth middleware so routes don't require real sessions/cookies.
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'test', group: 'Admin' }; req.user = { id: 1, username: 'test', group: 'Admin' };
next(); next();

View File

@@ -17,6 +17,7 @@ const express = require('express');
// Mock auth middleware // Mock auth middleware
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -17,6 +17,7 @@ const express = require('express');
// Mock auth middleware to bypass real session checks // Mock auth middleware to bypass real session checks
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -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. // Used by user management routes, auth middleware, and frontend-facing endpoints.
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; 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. * Parse and validate a comma-separated teams string.
* @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG') * @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 };

View File

@@ -1,5 +1,6 @@
// Authentication Middleware // Authentication Middleware
const pool = require('../db'); const pool = require('../db');
const { teamToIvanti } = require('../helpers/teams');
// Require authenticated user — no parameters needed, pool is imported directly // Require authenticated user — no parameters needed, pool is imported directly
function requireAuth() { 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 };

View File

@@ -4,7 +4,7 @@
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const { const {
isConfigured, isConfigured,
@@ -112,7 +112,7 @@ function createCardApiRouter() {
* @response 400 - { error: string } — missing disposition * @response 400 - { error: string } — missing disposition
* @response 503 - { error: string, missingVars: string[] } — CARD not configured * @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) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -120,6 +120,16 @@ function createCardApiRouter() {
const { teamName } = req.params; const { teamName } = req.params;
const { disposition, page, page_size } = req.query; 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) { if (!disposition) {
return res.status(400).json({ error: 'disposition query parameter is required.' }); return res.status(400).json({ error: 'disposition query parameter is required.' });
} }

View File

@@ -7,7 +7,7 @@ const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const pool = require('../db'); 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 { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker');
const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers'); const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
@@ -288,8 +288,9 @@ function computeWaterfall(uploads) {
function createComplianceRouter(upload) { function createComplianceRouter(upload) {
const router = express.Router(); const router = express.Router();
// All compliance routes require authentication // All compliance routes require authentication and team assignment
router.use(requireAuth()); router.use(requireAuth());
router.use(requireTeam());
/** /**
* POST /preview * POST /preview
@@ -537,6 +538,16 @@ function createComplianceRouter(upload) {
const team = req.query.team; const team = req.query.team;
if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid 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 {
// Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload // Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload
let { rows: latestRows } = await pool.query( 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 (!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' }); 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 { try {
// Include items from both AEO uploads (vertical IS NULL) and NTS_AEO multi-vertical uploads // 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 // DISTINCT ON deduplicates cross-vertical (hostname, metric_id) pairs, keeping the representative row

View File

@@ -4,7 +4,7 @@
// Daily auto-sync fetches from Ivanti API and upserts rows. // Daily auto-sync fetches from Ivanti API and upserts rows.
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth'); const { requireGroup, requireTeam } = require('../middleware/auth');
const { ivantiPost } = require('../helpers/ivantiApi'); const { ivantiPost } = require('../helpers/ivantiApi');
const pool = require('../db'); const pool = require('../db');
@@ -23,8 +23,10 @@ function formatDate(val) {
return String(val).slice(0, 10); return String(val).slice(0, 10);
} }
// Configurable BU filter — broadened via env var to support multi-tenancy. // Configurable BU filter — controls sync-level filtering (what gets pulled from Ivanti API).
// Users see only their assigned teams' findings (filtered at query time). // 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 BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
const FINDINGS_FILTERS = [ const FINDINGS_FILTERS = [
@@ -1079,32 +1081,29 @@ function createIvantiFindingsRouter(db, requireAuth) {
scheduleSync(); scheduleSync();
router.use(requireAuth()); router.use(requireAuth());
router.use(requireTeam());
/** /**
* GET /api/ivanti/findings * GET /api/ivanti/findings
* *
* Return findings from ivanti_findings table (state='open') with notes and overrides. * Return findings from ivanti_findings table (state='open') with notes and overrides.
* Accepts optional `teams` query parameter (comma-separated) to filter * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* findings by buOwnership. If omitted, returns all open findings. * 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} 200 - { findings, total, synced_at, sync_status, error_message }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const teamsParam = req.query.teams;
let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`; let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`;
const params = []; const params = [];
let paramIndex = 1; let paramIndex = 1;
if (teamsParam) { // Team scoping (null = admin bypass, no filter)
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (req.teamScope) {
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns);
params.push(patterns);
}
} }
query += ' ORDER BY severity DESC'; query += ' ORDER BY severity DESC';
@@ -1166,10 +1165,19 @@ function createIvantiFindingsRouter(db, requireAuth) {
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(); await syncFindings();
try { try {
// Return fresh state after sync // Return fresh state after sync, scoped to user's teams
const { rows } = await pool.query( let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`;
`SELECT * FROM ivanti_findings WHERE state = 'open' ORDER BY severity DESC` 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 => ({ const findings = rows.map(row => ({
id: row.id, id: row.id,
hostId: row.host_id, hostId: row.host_id,
@@ -1215,27 +1223,22 @@ function createIvantiFindingsRouter(db, requireAuth) {
* GET /api/ivanti/findings/counts * GET /api/ivanti/findings/counts
* *
* Return open vs closed finding totals. * Return open vs closed finding totals.
* Accepts optional `teams` query parameter to scope counts to specific BUs. * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* With Postgres, both open AND closed counts are per-BU when filtered.
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { open: number, closed: number, filtered: boolean } * @returns {Object} 200 - { open: number, closed: number, filtered: boolean }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/counts', async (req, res) => { router.get('/counts', async (req, res) => {
try { try {
const teamsParam = req.query.teams;
let whereExtra = ''; let whereExtra = '';
const params = []; const params = [];
let paramIndex = 1; let paramIndex = 1;
if (teamsParam) { // Team scoping (null = admin bypass, no filter)
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (req.teamScope) {
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns);
params.push(patterns);
}
} }
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -1246,7 +1249,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
const counts = { open: 0, closed: 0 }; const counts = { open: 0, closed: 0 };
rows.forEach(r => { counts[r.state] = parseInt(r.count); }); rows.forEach(r => { counts[r.state] = parseInt(r.count); });
res.json({ ...counts, filtered: !!teamsParam }); res.json({ ...counts, filtered: !!req.teamScope });
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] GET /counts error:', err.message); console.error('[Ivanti Findings] GET /counts error:', err.message);
res.status(500).json({ error: 'Database error reading counts' }); res.status(500).json({ error: 'Database error reading counts' });
@@ -1257,45 +1260,38 @@ function createIvantiFindingsRouter(db, requireAuth) {
* GET /api/ivanti/findings/counts/history * GET /api/ivanti/findings/counts/history
* *
* Return the last snapshot per day (ascending) for the trend chart. * Return the last snapshot per day (ascending) for the trend chart.
* Accepts optional `teams` query parameter to scope the trend to specific BUs. * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* When teams is provided, uses the per-BU history table. * When scoped, uses the per-BU history table. When admin (no scope), returns global aggregate.
* When no teams, returns the global aggregate history.
* *
* @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} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/counts/history', async (req, res) => { router.get('/counts/history', async (req, res) => {
try { try {
const teamsParam = req.query.teams; if (req.teamScope) {
// Per-BU history — filter and aggregate by user's assigned teams
if (teamsParam) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
// Per-BU history — filter and aggregate by selected teams const { rows } = await pool.query(
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); `SELECT date,
if (teams.length > 0) { SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count,
const patterns = teams.map(t => `%${t}%`); SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count
const { rows } = await pool.query( FROM (
`SELECT date, SELECT recorded_at::date AS date, bu_ownership, state, count,
SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count, ROW_NUMBER() OVER (
SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count PARTITION BY recorded_at::date, bu_ownership, state
FROM ( ORDER BY recorded_at DESC
SELECT recorded_at::date AS date, bu_ownership, state, count, ) AS rn
ROW_NUMBER() OVER ( FROM ivanti_counts_history_by_bu
PARTITION BY recorded_at::date, bu_ownership, state WHERE bu_ownership ILIKE ANY($1::text[])
ORDER BY recorded_at DESC ) sub WHERE rn = 1
) AS rn GROUP BY date
FROM ivanti_counts_history_by_bu ORDER BY date ASC`,
WHERE bu_ownership ILIKE ANY($1::text[]) [patterns]
) sub WHERE rn = 1 );
GROUP BY date return res.json({ history: rows });
ORDER BY date ASC`,
[patterns]
);
return res.json({ history: rows });
}
} }
// Global history (no filter) // Global history (admin — no filter)
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT date, open_count, closed_count FROM ( `SELECT date, open_count, closed_count FROM (
SELECT recorded_at::date AS date, 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), * Return FP finding counts and unique workflow ID counts (open + closed),
* broken down by workflow status. * 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} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/fp-workflow-counts', async (req, res) => { router.get('/fp-workflow-counts', async (req, res) => {
try { try {
const teamsParam = req.query.teams;
let whereExtra = ''; let whereExtra = '';
const params = []; const params = [];
let paramIndex = 1; let paramIndex = 1;
if (teamsParam) { // Team scoping (null = admin bypass, no filter)
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (req.teamScope) {
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns);
params.push(patterns);
}
} }
// Finding counts: number of findings per workflow state // Finding counts: number of findings per workflow state
@@ -1557,19 +1549,35 @@ function createIvantiFindingsRouter(db, requireAuth) {
* PUT /api/ivanti/findings/:findingId/override * PUT /api/ivanti/findings/:findingId/override
* *
* Save or clear field overrides for a finding. Requires Admin or Standard_User group. * 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) * @param {string} findingId - The finding identifier (URL param)
* @body {string} [hostName] - Override for host name; empty/null to clear * @body {string} [hostName] - Override for host name; empty/null to clear
* @body {string} [dns] - Override for DNS; empty/null to clear * @body {string} [dns] - Override for DNS; empty/null to clear
* *
* @returns {Object} 200 - { finding_id, overrides: { hostName, dns } } * @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} 404 - { error: string } when finding not found
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => {
try { try {
const { findingId } = req.params; 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; const { hostName, dns, field, value } = req.body;
// Support legacy single-field format: { field: 'hostName', value: 'x' } // 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). * Save or update a note for a finding (max 255 characters).
* Requires Admin or Standard_User group. * 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) * @param {string} findingId - The finding identifier (URL param)
* @body {string} [note] - The note text (truncated to 255 chars) * @body {string} [note] - The note text (truncated to 255 chars)
* *
* @returns {Object} 200 - { finding_id: string, note: string } * @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 * @returns {Object} 500 - { error: string } on database error
*/ */
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), async (req, res) => {
try { try {
const { findingId } = req.params; 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); const note = String(req.body.note || '').slice(0, 255);
await pool.query( await pool.query(

View File

@@ -1,7 +1,7 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
@@ -88,7 +88,7 @@ function createIvantiTodoQueueRouter() {
* @error 400 Invalid input * @error 400 Invalid input
* @error 500 Internal server error * @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; const { findings, workflow_type, vendor } = req.body;
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { 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)) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
} }