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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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(),
}));

View File

@@ -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(),
}));

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

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

View File

@@ -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 };

View File

@@ -4,7 +4,7 @@
const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const {
isConfigured,
@@ -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.' });
}

View File

@@ -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

View File

@@ -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,33 +1081,30 @@ 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}%`);
// 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,28 +1223,23 @@ 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}%`);
// 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(
`SELECT state, COUNT(*) as count FROM ivanti_findings WHERE 1=1 ${whereExtra} GROUP BY state`,
@@ -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,23 +1260,17 @@ 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}%`);
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,
@@ -1293,9 +1290,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
);
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,27 +1316,23 @@ 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}%`);
// 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
const findingResult = await pool.query(
@@ -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(

View File

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