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