Merge feature/team-and-page-access-enforcement
Add backend team enforcement, page visibility by group, and Admin impersonation (View As) feature. - requireTeam() middleware enforces team-scoped data access on Ivanti findings, compliance, CARD, Atlas, and archive routes - Centralized page visibility matrix controls nav and API access per group - Admin can View As any non-Admin user to verify permissions - Impersonation events hidden from non-Admin activity feed - db-schema.sql and steering files updated to reflect new patterns
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();
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
session_id VARCHAR(255) UNIQUE NOT NULL,
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -12,7 +13,8 @@ function requireAuth() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
|
`SELECT s.*, s.impersonate_user_id,
|
||||||
|
u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||||
@@ -29,8 +31,8 @@ function requireAuth() {
|
|||||||
return res.status(401).json({ error: 'Account is disabled' });
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach user to request
|
// Store the real admin identity (always the session owner)
|
||||||
req.user = {
|
req.realUser = {
|
||||||
id: session.user_id,
|
id: session.user_id,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
@@ -39,6 +41,35 @@ function requireAuth() {
|
|||||||
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If impersonating, load the target user's identity
|
||||||
|
if (session.impersonate_user_id) {
|
||||||
|
const { rows: targetRows } = await pool.query(
|
||||||
|
`SELECT id, username, email, role, user_group, bu_teams, is_active FROM users WHERE id = $1`,
|
||||||
|
[session.impersonate_user_id]
|
||||||
|
);
|
||||||
|
const target = targetRows[0];
|
||||||
|
|
||||||
|
if (target && target.is_active) {
|
||||||
|
req.user = {
|
||||||
|
id: target.id,
|
||||||
|
username: target.username,
|
||||||
|
email: target.email,
|
||||||
|
role: target.role,
|
||||||
|
group: target.user_group,
|
||||||
|
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
|
||||||
|
};
|
||||||
|
req.impersonating = true;
|
||||||
|
} else {
|
||||||
|
// Target user no longer valid — clear impersonation and use real user
|
||||||
|
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
|
||||||
|
req.user = req.realUser;
|
||||||
|
req.impersonating = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req.user = req.realUser;
|
||||||
|
req.impersonating = false;
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth middleware error:', err);
|
console.error('Auth middleware error:', err);
|
||||||
@@ -66,4 +97,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 };
|
||||||
|
|||||||
26
backend/migrations/add_session_impersonation.js
Normal file
26
backend/migrations/add_session_impersonation.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Migration: Add impersonate_user_id column to sessions table
|
||||||
|
// Allows Admin users to temporarily view the app as another user.
|
||||||
|
// When set, requireAuth() overrides req.user with the target user's identity.
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('[Migration] add_session_impersonation: starting...');
|
||||||
|
|
||||||
|
// Add impersonate_user_id column (nullable FK to users)
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('[Migration] add_session_impersonation: column added.');
|
||||||
|
console.log('[Migration] add_session_impersonation: done.');
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run directly if invoked as a script
|
||||||
|
if (require.main === module) {
|
||||||
|
run().catch(err => { console.error(err); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = run;
|
||||||
@@ -33,6 +33,7 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'add_ivanti_findings_ipv6_columns.js',
|
'add_ivanti_findings_ipv6_columns.js',
|
||||||
'add_user_ivanti_identity.js',
|
'add_user_ivanti_identity.js',
|
||||||
'add_atlas_known_column.js',
|
'add_atlas_known_column.js',
|
||||||
|
'add_session_impersonation.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const SECTION_MAX_LENGTH = 10000;
|
|||||||
function createArcherTemplatesRouter() {
|
function createArcherTemplatesRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All Archer template routes require authentication and Admin or Standard_User group (page-level access)
|
||||||
|
router.use(requireAuth());
|
||||||
|
router.use(requireGroup('Admin', 'Standard_User'));
|
||||||
|
|
||||||
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
|
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||||
|
|
||||||
@@ -70,49 +70,44 @@ function aggregateAtlasMetrics(rows) {
|
|||||||
function createAtlasRouter() {
|
function createAtlasRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All atlas routes require authentication and team scoping
|
||||||
|
router.use(requireAuth());
|
||||||
|
router.use(requireTeam());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /metrics
|
* GET /metrics
|
||||||
*
|
*
|
||||||
* Returns aggregated Atlas action plan metrics from the local cache.
|
* Returns aggregated Atlas action plan metrics from the local cache.
|
||||||
* Accepts optional `teams` query parameter to scope metrics to hosts
|
* Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs.
|
||||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
|
||||||
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on database failure
|
* @returns {Object} 500 - { error } on database failure
|
||||||
*/
|
*/
|
||||||
router.get('/metrics', requireAuth(), async (req, res) => {
|
router.get('/metrics', async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamsParam = req.query.teams;
|
|
||||||
let rows;
|
let rows;
|
||||||
|
|
||||||
if (teamsParam) {
|
if (req.teamScope) {
|
||||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
// Non-admin: scope to user's team findings
|
||||||
if (teams.length > 0) {
|
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
const result = await pool.query(
|
||||||
const result = await pool.query(
|
`SELECT a.has_action_plan, a.plans_json
|
||||||
`SELECT a.has_action_plan, a.plans_json
|
FROM atlas_action_plans_cache a
|
||||||
FROM atlas_action_plans_cache a
|
INNER JOIN (
|
||||||
INNER JOIN (
|
SELECT DISTINCT host_id FROM ivanti_findings
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
WHERE bu_ownership ILIKE ANY($1::text[])
|
||||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
) f ON a.host_id = f.host_id
|
||||||
) f ON a.host_id = f.host_id
|
WHERE a.atlas_known = true`,
|
||||||
WHERE a.atlas_known = true`,
|
[patterns]
|
||||||
[patterns]
|
);
|
||||||
);
|
rows = result.rows;
|
||||||
rows = result.rows;
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// Admin bypass — all cached plans
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
|
||||||
);
|
);
|
||||||
@@ -131,44 +126,35 @@ function createAtlasRouter() {
|
|||||||
* GET /status
|
* GET /status
|
||||||
*
|
*
|
||||||
* Returns atlas_action_plans_cache contents for status display.
|
* Returns atlas_action_plans_cache contents for status display.
|
||||||
* Accepts optional `teams` query parameter to scope results to hosts
|
* Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs.
|
||||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
|
||||||
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at }
|
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at }
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on database failure
|
* @returns {Object} 500 - { error } on database failure
|
||||||
*/
|
*/
|
||||||
router.get('/status', requireAuth(), async (req, res) => {
|
router.get('/status', async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamsParam = req.query.teams;
|
|
||||||
let rows;
|
let rows;
|
||||||
|
|
||||||
if (teamsParam) {
|
if (req.teamScope) {
|
||||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
// Non-admin: scope to user's team findings
|
||||||
if (teams.length > 0) {
|
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
const result = await pool.query(
|
||||||
const result = await pool.query(
|
`SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at
|
||||||
`SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at
|
FROM atlas_action_plans_cache a
|
||||||
FROM atlas_action_plans_cache a
|
INNER JOIN (
|
||||||
INNER JOIN (
|
SELECT DISTINCT host_id FROM ivanti_findings
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
WHERE bu_ownership ILIKE ANY($1::text[])
|
||||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
) f ON a.host_id = f.host_id`,
|
||||||
) f ON a.host_id = f.host_id`,
|
[patterns]
|
||||||
[patterns]
|
);
|
||||||
);
|
rows = result.rows;
|
||||||
rows = result.rows;
|
|
||||||
} else {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
|
||||||
);
|
|
||||||
rows = result.rows;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// Admin bypass — all cached entries
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
|
||||||
);
|
);
|
||||||
@@ -187,64 +173,40 @@ function createAtlasRouter() {
|
|||||||
*
|
*
|
||||||
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
||||||
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
||||||
* Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS.
|
* Team scoping enforced by requireTeam() — syncs only hosts in user's BUs.
|
||||||
|
* Falls back to IVANTI_MANAGED_BUS for admin when no team scope is set.
|
||||||
* Requires Admin or Standard_User group.
|
* Requires Admin or Standard_User group.
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG')
|
|
||||||
* @param {Object} [req.body]
|
|
||||||
* @param {string} [req.body.teams] - Comma-separated team names (alternative to query param)
|
|
||||||
* @returns {Object} 200 - { synced, withPlans, failed }
|
* @returns {Object} 200 - { synced, withPlans, failed }
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on unexpected failure
|
* @returns {Object} 500 - { error } on unexpected failure
|
||||||
*/
|
*/
|
||||||
router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Scope sync to the user's active teams if provided, otherwise sync only
|
// Use team scope from middleware, fall back to managed BUs for admin
|
||||||
// findings from managed BUs (IVANTI_MANAGED_BUS) to avoid polluting cache
|
|
||||||
// with "no plan" entries for BUs not covered by Atlas.
|
|
||||||
const teamsParam = req.query.teams || req.body.teams || '';
|
|
||||||
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
|
||||||
.split(',').map(b => b.trim()).filter(Boolean);
|
.split(',').map(b => b.trim()).filter(Boolean);
|
||||||
|
|
||||||
let findingsRows;
|
let patterns;
|
||||||
if (teamsParam) {
|
if (req.teamScope) {
|
||||||
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
patterns = req.teamScope.ivanti.map(t => `%${t}%`);
|
||||||
if (teams.length > 0) {
|
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[patterns]
|
|
||||||
);
|
|
||||||
findingsRows = result.rows;
|
|
||||||
} else {
|
|
||||||
// No valid teams — fall back to managed BUs
|
|
||||||
const patterns = managedBUs.map(b => `%${b}%`);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[patterns]
|
|
||||||
);
|
|
||||||
findingsRows = result.rows;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No teams specified — default to managed BUs only
|
// Admin with no specific scope — sync managed BUs
|
||||||
const patterns = managedBUs.map(b => `%${b}%`);
|
patterns = managedBUs.map(b => `%${b}%`);
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT host_id FROM ivanti_findings
|
|
||||||
WHERE host_id IS NOT NULL AND host_id > 0
|
|
||||||
AND bu_ownership ILIKE ANY($1::text[])`,
|
|
||||||
[patterns]
|
|
||||||
);
|
|
||||||
findingsRows = result.rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT host_id FROM ivanti_findings
|
||||||
|
WHERE host_id IS NOT NULL AND host_id > 0
|
||||||
|
AND bu_ownership ILIKE ANY($1::text[])`,
|
||||||
|
[patterns]
|
||||||
|
);
|
||||||
|
const findingsRows = result.rows;
|
||||||
|
|
||||||
const hostIds = findingsRows.map(r => r.host_id);
|
const hostIds = findingsRows.map(r => r.host_id);
|
||||||
|
|
||||||
if (hostIds.length === 0) {
|
if (hostIds.length === 0) {
|
||||||
|
|||||||
@@ -195,9 +195,10 @@ function createAuthRouter(logAudit) {
|
|||||||
* GET /api/auth/me
|
* GET /api/auth/me
|
||||||
*
|
*
|
||||||
* Returns the currently authenticated user based on the session cookie.
|
* Returns the currently authenticated user based on the session cookie.
|
||||||
* Clears the cookie and returns 401 if the session is expired or the account is disabled.
|
* If impersonating, returns the impersonated user's identity with an
|
||||||
|
* `impersonating` flag and the real admin user's info.
|
||||||
*
|
*
|
||||||
* @returns {object} 200 - { user: { id, username, email, group } }
|
* @returns {object} 200 - { user: { id, username, email, group, teams }, impersonating?: boolean, realUser?: { id, username, group } }
|
||||||
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
|
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
|
||||||
* @returns {object} 500 - { error: 'Failed to get user' }
|
* @returns {object} 500 - { error: 'Failed to get user' }
|
||||||
*/
|
*/
|
||||||
@@ -210,7 +211,8 @@ function createAuthRouter(logAudit) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
`SELECT s.*, s.impersonate_user_id,
|
||||||
|
u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||||
@@ -229,6 +231,36 @@ function createAuthRouter(logAudit) {
|
|||||||
return res.status(401).json({ error: 'Account is disabled' });
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If impersonating, return target user's identity
|
||||||
|
if (session.impersonate_user_id) {
|
||||||
|
const { rows: targetRows } = await pool.query(
|
||||||
|
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
|
||||||
|
[session.impersonate_user_id]
|
||||||
|
);
|
||||||
|
const target = targetRows[0];
|
||||||
|
|
||||||
|
if (target && target.is_active) {
|
||||||
|
return res.json({
|
||||||
|
user: {
|
||||||
|
id: target.id,
|
||||||
|
username: target.username,
|
||||||
|
email: target.email,
|
||||||
|
group: target.user_group,
|
||||||
|
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
|
||||||
|
},
|
||||||
|
impersonating: true,
|
||||||
|
realUser: {
|
||||||
|
id: session.user_id,
|
||||||
|
username: session.username,
|
||||||
|
group: session.user_group
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Target invalid — clear impersonation
|
||||||
|
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: {
|
user: {
|
||||||
id: session.user_id,
|
id: session.user_id,
|
||||||
@@ -244,6 +276,133 @@ function createAuthRouter(logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/impersonate
|
||||||
|
*
|
||||||
|
* Start impersonating another user. Only Admin group can impersonate.
|
||||||
|
* Cannot impersonate another Admin user.
|
||||||
|
*
|
||||||
|
* @body {number} userId - The ID of the user to impersonate
|
||||||
|
* @returns {object} 200 - { message, user: { id, username, group, teams } }
|
||||||
|
* @returns {object} 400 - { error } — cannot impersonate Admin or self
|
||||||
|
* @returns {object} 403 - { error } — not Admin
|
||||||
|
* @returns {object} 404 - { error } — target user not found
|
||||||
|
* @returns {object} 500 - { error }
|
||||||
|
*/
|
||||||
|
router.post('/impersonate', requireAuth(), async (req, res) => {
|
||||||
|
// Only the real user (not an impersonated identity) can start impersonation
|
||||||
|
const realUser = req.realUser || req.user;
|
||||||
|
|
||||||
|
if (realUser.group !== 'Admin') {
|
||||||
|
return res.status(403).json({ error: 'Only Admin users can impersonate.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = req.body;
|
||||||
|
if (!userId || typeof userId !== 'number') {
|
||||||
|
return res.status(400).json({ error: 'userId is required and must be a number.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId === realUser.id) {
|
||||||
|
return res.status(400).json({ error: 'Cannot impersonate yourself.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const target = rows[0];
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return res.status(404).json({ error: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.is_active) {
|
||||||
|
return res.status(400).json({ error: 'Cannot impersonate a disabled account.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.user_group === 'Admin') {
|
||||||
|
return res.status(400).json({ error: 'Cannot impersonate another Admin user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set impersonation on the session
|
||||||
|
const sessionId = req.cookies?.session_id;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE sessions SET impersonate_user_id = $1 WHERE session_id = $2`,
|
||||||
|
[userId, sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: realUser.id,
|
||||||
|
username: realUser.username,
|
||||||
|
action: 'impersonate_start',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: String(userId),
|
||||||
|
details: { target_username: target.username, target_group: target.user_group },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Now viewing as ${target.username}`,
|
||||||
|
user: {
|
||||||
|
id: target.id,
|
||||||
|
username: target.username,
|
||||||
|
email: target.email,
|
||||||
|
group: target.user_group,
|
||||||
|
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Impersonate error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to start impersonation.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/stop-impersonate
|
||||||
|
*
|
||||||
|
* Stop impersonating and revert to the real Admin identity.
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - { message, user: { id, username, group, teams } }
|
||||||
|
* @returns {object} 500 - { error }
|
||||||
|
*/
|
||||||
|
router.post('/stop-impersonate', requireAuth(), async (req, res) => {
|
||||||
|
const sessionId = req.cookies?.session_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const realUser = req.realUser || req.user;
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: realUser.id,
|
||||||
|
username: realUser.username,
|
||||||
|
action: 'impersonate_stop',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: null,
|
||||||
|
details: null,
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Impersonation ended',
|
||||||
|
user: {
|
||||||
|
id: realUser.id,
|
||||||
|
username: realUser.username,
|
||||||
|
email: realUser.email,
|
||||||
|
group: realUser.group,
|
||||||
|
teams: realUser.teams
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Stop impersonate error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to stop impersonation.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/profile
|
* GET /api/auth/profile
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Ivanti Archive Routes — list, stats, and transition history for archived findings
|
// Ivanti Archive Routes — list, stats, and transition history for archived findings
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth, requireTeam } = require('../middleware/auth');
|
||||||
|
|
||||||
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
||||||
|
|
||||||
@@ -30,18 +30,18 @@ function findRelatedActive(archive, activeFindings) {
|
|||||||
function createIvantiArchiveRouter() {
|
function createIvantiArchiveRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// All routes require authentication
|
// All routes require authentication and team scoping
|
||||||
router.use(requireAuth());
|
router.use(requireAuth());
|
||||||
|
router.use(requireTeam());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /
|
* GET /
|
||||||
* List archive records with optional state and teams filtering.
|
* List archive records with optional state filtering.
|
||||||
|
* Team scoping enforced by requireTeam() middleware via req.teamScope.
|
||||||
*
|
*
|
||||||
* @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED.
|
* @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED.
|
||||||
* When state=ACTIVE, returns live open findings from ivanti_findings instead of archives.
|
* When state=ACTIVE, returns live open findings from ivanti_findings instead of archives.
|
||||||
* When state=CLOSED, includes both CLOSED and CLOSED_GONE records.
|
* When state=CLOSED, includes both CLOSED and CLOSED_GONE records.
|
||||||
* @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG').
|
|
||||||
* Filters results to findings whose bu_ownership contains one of the specified teams.
|
|
||||||
*
|
*
|
||||||
* @response {object} 200
|
* @response {object} 200
|
||||||
* { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number }
|
* { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number }
|
||||||
@@ -51,7 +51,7 @@ function createIvantiArchiveRouter() {
|
|||||||
* { error: string }
|
* { error: string }
|
||||||
*/
|
*/
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { state, teams } = req.query;
|
const { state } = req.query;
|
||||||
|
|
||||||
if (state && !VALID_STATES.includes(state)) {
|
if (state && !VALID_STATES.includes(state)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -59,9 +59,9 @@ function createIvantiArchiveRouter() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse teams filter into ILIKE patterns
|
// Build team patterns from middleware (null = admin, no filter)
|
||||||
const teamPatterns = teams
|
const teamPatterns = req.teamScope
|
||||||
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%')
|
? req.teamScope.ivanti.map(t => `%${t}%`)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -148,9 +148,7 @@ function createIvantiArchiveRouter() {
|
|||||||
/**
|
/**
|
||||||
* GET /stats
|
* GET /stats
|
||||||
* Summary counts of archive records grouped by lifecycle state.
|
* Summary counts of archive records grouped by lifecycle state.
|
||||||
*
|
* Team scoping enforced by requireTeam() middleware via req.teamScope.
|
||||||
* @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG').
|
|
||||||
* Filters counts to findings whose bu_ownership contains one of the specified teams.
|
|
||||||
*
|
*
|
||||||
* @response {object} 200
|
* @response {object} 200
|
||||||
* { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
* { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||||
@@ -159,9 +157,9 @@ function createIvantiArchiveRouter() {
|
|||||||
*/
|
*/
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { teams } = req.query;
|
// Build team patterns from middleware (null = admin, no filter)
|
||||||
const teamPatterns = teams
|
const teamPatterns = req.teamScope
|
||||||
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%')
|
? req.teamScope.ivanti.map(t => `%${t}%`)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
let archiveQuery, archiveParams = [];
|
let archiveQuery, archiveParams = [];
|
||||||
@@ -190,7 +188,7 @@ function createIvantiArchiveRouter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACTIVE = total live findings count (scoped by teams if provided)
|
// ACTIVE = total live findings count (scoped by teams)
|
||||||
let activeQuery, activeParams = [];
|
let activeQuery, activeParams = [];
|
||||||
if (teamPatterns.length > 0) {
|
if (teamPatterns.length > 0) {
|
||||||
activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`;
|
activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`;
|
||||||
|
|||||||
@@ -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.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ function isValidVendor(vendor) {
|
|||||||
function createJiraTicketsRouter() {
|
function createJiraTicketsRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All Jira routes require authentication and Admin or Standard_User group (page-level access)
|
||||||
|
router.use(requireAuth());
|
||||||
|
router.use(requireGroup('Admin', 'Standard_User'));
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Jira API integration endpoints
|
// Jira API integration endpoints
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -161,13 +161,18 @@ app.use('/api/audit-logs', createAuditLogRouter());
|
|||||||
app.get('/api/recent-activity', requireAuth(), async (req, res) => {
|
app.get('/api/recent-activity', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(15, Math.max(1, parseInt(req.query.limit) || 10));
|
const limit = Math.min(15, Math.max(1, parseInt(req.query.limit) || 10));
|
||||||
|
// Hide impersonation events from non-Admin users
|
||||||
|
const excludedActions = ['login', 'logout', 'login_failed'];
|
||||||
|
if (req.user.group !== 'Admin') {
|
||||||
|
excludedActions.push('impersonate_start', 'impersonate_stop');
|
||||||
|
}
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT username, action, entity_type, entity_id, details, created_at
|
`SELECT username, action, entity_type, entity_id, details, created_at
|
||||||
FROM audit_logs
|
FROM audit_logs
|
||||||
WHERE action NOT IN ('login', 'logout', 'login_failed')
|
WHERE action NOT IN (${excludedActions.map((_, i) => `$${i + 1}`).join(', ')})
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $1`,
|
LIMIT $${excludedActions.length + 1}`,
|
||||||
[limit]
|
[...excludedActions, limit]
|
||||||
);
|
);
|
||||||
res.json({ activities: rows });
|
res.json({ activities: rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import AuditLog from './components/AuditLog';
|
|||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
import NavDrawer from './components/NavDrawer';
|
import NavDrawer from './components/NavDrawer';
|
||||||
import AdminScopeToggle from './components/AdminScopeToggle';
|
import AdminScopeToggle from './components/AdminScopeToggle';
|
||||||
|
import ImpersonationBanner from './components/ImpersonationBanner';
|
||||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
@@ -19,20 +20,20 @@ import ArcherTemplatePage from './components/pages/ArcherTemplatePage';
|
|||||||
import HomePage from './components/pages/HomePage';
|
import HomePage from './components/pages/HomePage';
|
||||||
import FeedbackModal from './components/FeedbackModal';
|
import FeedbackModal from './components/FeedbackModal';
|
||||||
import NotificationBell from './components/NotificationBell';
|
import NotificationBell from './components/NotificationBell';
|
||||||
|
import { canAccessPage } from './config/pageVisibility';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']);
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isAuthenticated, loading: authLoading, canWrite, isAdmin, isInGroup } = useAuth();
|
const { isAuthenticated, loading: authLoading, canWrite, user } = useAuth();
|
||||||
|
|
||||||
const [currentPage, setCurrentPageRaw] = useState(() => {
|
const [currentPage, setCurrentPageRaw] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('cve-dashboard-page');
|
const saved = localStorage.getItem('cve-dashboard-page');
|
||||||
return saved && VALID_PAGES.has(saved) ? saved : 'home';
|
return saved && canAccessPage(saved, user?.group) ? saved : 'home';
|
||||||
} catch { return 'home'; }
|
} catch { return 'home'; }
|
||||||
});
|
});
|
||||||
const setCurrentPage = (page) => {
|
const setCurrentPage = (page) => {
|
||||||
|
if (!canAccessPage(page, user?.group)) { setCurrentPageRaw('home'); return; }
|
||||||
setCurrentPageRaw(page);
|
setCurrentPageRaw(page);
|
||||||
try { localStorage.setItem('cve-dashboard-page', page); } catch {}
|
try { localStorage.setItem('cve-dashboard-page', page); } catch {}
|
||||||
};
|
};
|
||||||
@@ -75,6 +76,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||||
|
<ImpersonationBanner />
|
||||||
<NavDrawer
|
<NavDrawer
|
||||||
isOpen={navOpen}
|
isOpen={navOpen}
|
||||||
onClose={() => setNavOpen(false)}
|
onClose={() => setNavOpen(false)}
|
||||||
@@ -160,18 +162,16 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content — generic route guard via canAccessPage */}
|
||||||
{currentPage === 'home' && <HomePage onNavigate={handleNavigate} showAddCVE={showAddCVE} setShowAddCVE={setShowAddCVE} />}
|
{currentPage === 'home' && <HomePage onNavigate={handleNavigate} showAddCVE={showAddCVE} setShowAddCVE={setShowAddCVE} />}
|
||||||
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||||
{currentPage === 'ccp-metrics' && isInGroup('Admin', 'Leadership') && <CCPMetricsPage />}
|
{currentPage === 'ccp-metrics' && <CCPMetricsPage />}
|
||||||
{currentPage === 'ccp-metrics' && !isInGroup('Admin', 'Leadership') && (() => { setCurrentPage('home'); return null; })()}
|
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
{currentPage === 'jira' && <JiraPage />}
|
{currentPage === 'jira' && <JiraPage />}
|
||||||
{currentPage === 'archer-templates' && <ArcherTemplatePage />}
|
{currentPage === 'archer-templates' && <ArcherTemplatePage />}
|
||||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
{currentPage === 'admin' && <AdminPage />}
|
||||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
|
||||||
|
|
||||||
{/* Global Modals */}
|
{/* Global Modals */}
|
||||||
{showUserManagement && <UserManagement onClose={() => setShowUserManagement(false)} />}
|
{showUserManagement && <UserManagement onClose={() => setShowUserManagement(false)} />}
|
||||||
|
|||||||
69
frontend/src/components/ImpersonationBanner.js
Normal file
69
frontend/src/components/ImpersonationBanner.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Eye, X } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImpersonationBanner — renders a fixed banner at the top of the viewport
|
||||||
|
* when an Admin is viewing the app as another user. Shows who is being
|
||||||
|
* impersonated and provides a button to exit.
|
||||||
|
*/
|
||||||
|
export default function ImpersonationBanner() {
|
||||||
|
const { impersonating, user, realUser, stopImpersonation } = useAuth();
|
||||||
|
|
||||||
|
if (!impersonating) return null;
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
await stopImpersonation();
|
||||||
|
// Force page reload to reset all state to admin's view
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'linear-gradient(90deg, #D97706 0%, #B45309 100%)',
|
||||||
|
color: '#FFF',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
|
||||||
|
}}>
|
||||||
|
<Eye style={{ width: '16px', height: '16px', flexShrink: 0 }} />
|
||||||
|
<span>
|
||||||
|
Viewing as: <strong>{user?.username}</strong> ({user?.group}, teams: {user?.teams?.join(', ') || 'none'})
|
||||||
|
{realUser && <span style={{ opacity: 0.8 }}> — logged in as {realUser.username}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.4)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.35)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.2)'}
|
||||||
|
>
|
||||||
|
<X style={{ width: '12px', height: '12px' }} />
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react';
|
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { canAccessPage } from '../config/pageVisibility';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||||
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting', requiredGroups: ['Admin', 'Leadership'] },
|
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' },
|
||||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||||
@@ -16,7 +17,7 @@ const NAV_ITEMS = [
|
|||||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||||
|
|
||||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||||
const { isAdmin, isInGroup } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
{NAV_ITEMS.filter(({ requiredGroups }) => !requiredGroups || isInGroup(...requiredGroups)).map(({ id, label, icon: Icon, color, description }) => {
|
{NAV_ITEMS.filter(item => canAccessPage(item.id, user?.group)).map(({ id, label, icon: Icon, color, description }) => {
|
||||||
const active = currentPage === id;
|
const active = currentPage === id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -124,8 +125,8 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Admin panel link — visible only to Admin group */}
|
{/* Admin panel link — visible based on page visibility matrix */}
|
||||||
{isAdmin() && (() => {
|
{canAccessPage('admin', user?.group) && (() => {
|
||||||
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
|
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
|
||||||
const active = currentPage === id;
|
const active = currentPage === id;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
||||||
// - The ConfirmModal integration for delete/group-change confirmations
|
// - The ConfirmModal integration for delete/group-change confirmations
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
|
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield, Eye } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ const styles = {
|
|||||||
|
|
||||||
|
|
||||||
export default function UserManagement({ onClose }) {
|
export default function UserManagement({ onClose }) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser, startImpersonation } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -665,6 +665,20 @@ export default function UserManagement({ onClose }) {
|
|||||||
</td>
|
</td>
|
||||||
<td style={styles.tdRight}>
|
<td style={styles.tdRight}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
|
||||||
|
{currentUser.group === 'Admin' && user.id !== currentUser.id && user.group !== 'Admin' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const result = await startImpersonation(user.id);
|
||||||
|
if (result.success) window.location.reload();
|
||||||
|
}}
|
||||||
|
style={styles.actionBtn}
|
||||||
|
title={`View as ${user.username}`}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#D97706'; e.currentTarget.style.background = 'rgba(217,119,6,0.1)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'none'; }}
|
||||||
|
>
|
||||||
|
<Eye style={{ width: '1rem', height: '1rem' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(user)}
|
onClick={() => handleEdit(user)}
|
||||||
style={styles.actionBtn}
|
style={styles.actionBtn}
|
||||||
|
|||||||
37
frontend/src/config/pageVisibility.js
Normal file
37
frontend/src/config/pageVisibility.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Centralized Page Visibility Matrix
|
||||||
|
// Controls which pages each user group can access.
|
||||||
|
// Empty array = visible to all authenticated users.
|
||||||
|
// Non-empty array = visible only to listed groups.
|
||||||
|
|
||||||
|
export const PAGE_VISIBILITY = {
|
||||||
|
home: [],
|
||||||
|
triage: ['Admin', 'Standard_User', 'Leadership'],
|
||||||
|
compliance: ['Admin', 'Standard_User', 'Leadership'],
|
||||||
|
'ccp-metrics': ['Admin', 'Leadership'],
|
||||||
|
'knowledge-base': [],
|
||||||
|
exports: ['Admin', 'Standard_User', 'Leadership'],
|
||||||
|
jira: ['Admin', 'Standard_User'],
|
||||||
|
'archer-templates': ['Admin', 'Standard_User'],
|
||||||
|
admin: ['Admin'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user group can access a page.
|
||||||
|
* @param {string} pageId - Page identifier
|
||||||
|
* @param {string} userGroup - User's group
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function canAccessPage(pageId, userGroup) {
|
||||||
|
const allowed = PAGE_VISIBILITY[pageId];
|
||||||
|
if (!allowed || allowed.length === 0) return true;
|
||||||
|
return allowed.includes(userGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all page IDs accessible to a given group.
|
||||||
|
* @param {string} userGroup - User's group
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getAccessiblePages(userGroup) {
|
||||||
|
return Object.keys(PAGE_VISIBILITY).filter(pageId => canAccessPage(pageId, userGroup));
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ export function AuthProvider({ children }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Impersonation state
|
||||||
|
const [impersonating, setImpersonating] = useState(false);
|
||||||
|
const [realUser, setRealUser] = useState(null);
|
||||||
|
|
||||||
// Admin scope — array of currently selected teams for filtering
|
// Admin scope — array of currently selected teams for filtering
|
||||||
// null = not initialized yet (will default to user's teams after login)
|
// null = not initialized yet (will default to user's teams after login)
|
||||||
const [adminScope, setAdminScope] = useState(loadAdminScope);
|
const [adminScope, setAdminScope] = useState(loadAdminScope);
|
||||||
@@ -45,6 +49,14 @@ export function AuthProvider({ children }) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
// Handle impersonation state from backend
|
||||||
|
if (data.impersonating) {
|
||||||
|
setImpersonating(true);
|
||||||
|
setRealUser(data.realUser);
|
||||||
|
} else {
|
||||||
|
setImpersonating(false);
|
||||||
|
setRealUser(null);
|
||||||
|
}
|
||||||
// Initialize admin scope to user's teams if not yet set
|
// Initialize admin scope to user's teams if not yet set
|
||||||
if (adminScope === null && data.user?.teams?.length > 0) {
|
if (adminScope === null && data.user?.teams?.length > 0) {
|
||||||
const initial = data.user.teams;
|
const initial = data.user.teams;
|
||||||
@@ -53,6 +65,8 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setImpersonating(false);
|
||||||
|
setRealUser(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auth check error:', err);
|
console.error('Auth check error:', err);
|
||||||
@@ -198,6 +212,43 @@ export function AuthProvider({ children }) {
|
|||||||
canExport,
|
canExport,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
|
// Impersonation
|
||||||
|
impersonating,
|
||||||
|
realUser,
|
||||||
|
startImpersonation: async (userId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/impersonate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ userId })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) return { success: false, error: data.error };
|
||||||
|
setUser(data.user);
|
||||||
|
setImpersonating(true);
|
||||||
|
setRealUser({ id: user.id, username: user.username, group: user.group });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopImpersonation: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/stop-impersonate`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) return { success: false, error: data.error };
|
||||||
|
setUser(data.user);
|
||||||
|
setImpersonating(false);
|
||||||
|
setRealUser(null);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
// Multi-BU tenancy
|
// Multi-BU tenancy
|
||||||
hasTeams,
|
hasTeams,
|
||||||
isTeamMember,
|
isTeamMember,
|
||||||
|
|||||||
Reference in New Issue
Block a user