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:
@@ -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.' });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user