feat: add multi-BU tenancy with per-user team scoping (Option B)
- Add bu_teams column to users table (migration + fresh schema) - Create shared KNOWN_TEAMS constant and validateTeams helper - Expose user teams in auth middleware, login, and /me responses - Add bu_teams CRUD to user management routes with audit logging - Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var - Add query-time team filtering to GET /findings and /findings/counts - Update AuthContext with teams helpers and admin scope toggle - Create AdminScopeToggle component (My Teams / All BUs) - Scope ReportingPage findings fetch by user teams - Scope CompliancePage team selector by user teams - Scope ExportsPage findings exports by user teams - Add BU teams multi-select to UserManagement create/edit forms - Display team badges in user list table
This commit is contained in:
@@ -17,6 +17,11 @@ IVANTI_API_KEY=
|
||||
IVANTI_CLIENT_ID=1550
|
||||
IVANTI_FIRST_NAME=
|
||||
IVANTI_LAST_NAME=
|
||||
# Comma-separated list of BU values to sync from Ivanti.
|
||||
# Broadening this pulls findings for additional BUs into the local cache.
|
||||
# Users see only their assigned teams' findings (filtered at query time).
|
||||
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||
IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||
IVANTI_SKIP_TLS=false
|
||||
|
||||
@@ -54,3 +59,9 @@ CARD_API_USER=
|
||||
CARD_API_PASS=
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
CARD_SKIP_TLS=false
|
||||
|
||||
# GitLab Feedback Integration (bug reports and feature requests from the dashboard)
|
||||
# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings.
|
||||
GITLAB_URL=http://steam-gitlab.charterlab.com
|
||||
GITLAB_PROJECT_ID=
|
||||
GITLAB_PAT=
|
||||
|
||||
108
backend/__tests__/jira-jql-window-invariant.property.test.js
Normal file
108
backend/__tests__/jira-jql-window-invariant.property.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Property-Based Test: JQL Window Invariant
|
||||
*
|
||||
* Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72 hours in bulk sync
|
||||
*
|
||||
* For any non-empty array of valid-looking issue keys passed to searchIssuesByKeys(),
|
||||
* the generated JQL string SHALL contain the substring `updated >= -72h` and
|
||||
* SHALL contain the substring `project =`.
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.3
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// Capture the JQL that flows through the HTTP layer.
|
||||
let capturedJql = null;
|
||||
|
||||
// Mock https to intercept the request URL (which contains the JQL) and return
|
||||
// a fake 200 response. This prevents real network calls while letting the
|
||||
// real searchIssuesByKeys → searchIssues → jiraGet → jiraRequest chain execute.
|
||||
jest.mock('https', () => ({
|
||||
request: jest.fn((options, callback) => {
|
||||
const fullPath = options.path || '';
|
||||
const jqlMatch = fullPath.match(/[?&]jql=([^&]*)/);
|
||||
if (jqlMatch) {
|
||||
capturedJql = decodeURIComponent(jqlMatch[1]);
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'data') {
|
||||
handler(JSON.stringify({ total: 0, issues: [] }));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
// Use setImmediate so the callback fires on the same tick after promises
|
||||
// resolve, but still asynchronously as Node's http expects.
|
||||
setImmediate(() => callback(mockResponse));
|
||||
|
||||
return {
|
||||
on: jest.fn(),
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Set required env vars before requiring the module so the module-level
|
||||
// constants pick them up.
|
||||
process.env.JIRA_PROJECT_KEY = 'TESTPROJ';
|
||||
process.env.JIRA_BASE_URL = 'https://jira.example.com';
|
||||
process.env.JIRA_API_USER = 'testuser';
|
||||
process.env.JIRA_API_TOKEN = 'testtoken';
|
||||
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72h in bulk sync', () => {
|
||||
// Use fake timers so the rate-limiter's inter-request delays (1–2 seconds)
|
||||
// resolve instantly. We preserve setImmediate so the https mock callback
|
||||
// still fires asynchronously as expected.
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
capturedJql = null;
|
||||
});
|
||||
|
||||
// Generator: produces a valid Jira issue key like "AB-1", "PROJ-42", etc.
|
||||
const issueKeyArb = fc.tuple(
|
||||
fc.stringMatching(/^[A-Z]{2,10}$/),
|
||||
fc.integer({ min: 1, max: 99999 })
|
||||
).map(([prefix, num]) => `${prefix}-${num}`);
|
||||
|
||||
// Generator: non-empty array of issue keys (1 to 50 keys)
|
||||
const issueKeysArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 50 });
|
||||
|
||||
it('searchIssuesByKeys() always generates JQL containing `updated >= -72h` and `project =`', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(issueKeysArb, async (issueKeys) => {
|
||||
capturedJql = null;
|
||||
|
||||
// Start the call — it will hit waitForDelay which uses setTimeout
|
||||
const promise = jiraApi.searchIssuesByKeys(issueKeys);
|
||||
|
||||
// Advance fake timers to resolve any pending setTimeout from the
|
||||
// rate limiter's waitForDelay function.
|
||||
jest.advanceTimersByTime(5000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(capturedJql).not.toBeNull();
|
||||
expect(capturedJql).toContain('updated >= -72h');
|
||||
expect(capturedJql).toContain('project =');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
146
backend/__tests__/jira-route-removal.test.js
Normal file
146
backend/__tests__/jira-route-removal.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Example-Based Tests: Route Removal and Remaining Routes
|
||||
*
|
||||
* Feature: jira-api-compliance-cleanup
|
||||
*
|
||||
* Property 2: Search route is absent from router (Example)
|
||||
* After the route removal, a POST request to /api/jira/search SHALL return HTTP 404.
|
||||
* Validates: Requirements 1.1, 1.2
|
||||
*
|
||||
* Property 3: Existing routes remain functional after search route removal (Example)
|
||||
* The routes GET /lookup/:issueKey, POST /sync-all, POST /:id/sync, and
|
||||
* POST /create-in-jira SHALL continue to respond with non-404 status codes.
|
||||
* Validates: Requirements 1.3, 1.4, 1.5, 1.6
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock the auth middleware so routes don't require real sessions/cookies.
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
req.user = { id: 1, username: 'test', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock the audit log helper to be a no-op.
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the jiraApi helper — mark it as not configured so routes return 503
|
||||
// (which is fine; we only care that they are NOT 404).
|
||||
jest.mock('../helpers/jiraApi', () => ({
|
||||
isConfigured: false,
|
||||
getRateLimitStatus: jest.fn(() => ({
|
||||
burst: { remaining: 60, limit: 60 },
|
||||
daily: { remaining: 1440, limit: 1440 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
||||
|
||||
// Minimal db mock — callback-style methods that return empty results.
|
||||
function createMockDb() {
|
||||
return {
|
||||
get: jest.fn((_sql, _params, cb) => cb(null, null)),
|
||||
all: jest.fn((_sql, _params, cb) => cb(null, [])),
|
||||
run: jest.fn(function (_sql, _params, cb) {
|
||||
if (typeof cb === 'function') cb.call({ lastID: 1, changes: 0 }, null);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request to the test server and return { statusCode }.
|
||||
*/
|
||||
function request(server, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
// Consume the response body so the socket closes cleanly.
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve({ statusCode: res.statusCode });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Feature: jira-api-compliance-cleanup — route removal tests', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
const db = createMockDb();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||
|
||||
// Listen on a random available port.
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: POST /api/jira-tickets/search returns 404
|
||||
// Validates: Requirements 1.1, 1.2
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Property 2: Search route is absent', () => {
|
||||
it('POST /api/jira-tickets/search returns HTTP 404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/search', {
|
||||
jql: 'project = TEST',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 3: Remaining routes respond with non-404 status codes
|
||||
// Validates: Requirements 1.3, 1.4, 1.5, 1.6
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Property 3: Existing routes remain functional', () => {
|
||||
it('GET /api/jira-tickets/lookup/:issueKey returns non-404', async () => {
|
||||
const res = await request(server, 'GET', '/api/jira-tickets/lookup/TEST-1');
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/jira-tickets/sync-all returns non-404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/sync-all');
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/jira-tickets/:id/sync returns non-404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/sync');
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/jira-tickets/create-in-jira returns non-404', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/create-in-jira', {
|
||||
cve_id: 'CVE-2024-12345',
|
||||
vendor: 'TestVendor',
|
||||
summary: 'Test summary',
|
||||
});
|
||||
expect(res.statusCode).not.toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -304,7 +304,7 @@ async function searchIssuesByKeys(issueKeys, opts) {
|
||||
// or similar, but key-based search is inherently scoped. We add updated
|
||||
// clause for compliance.
|
||||
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||||
const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`;
|
||||
const jql = `key in (${keyList}) AND updated >= -72h AND project = ${JIRA_PROJECT_KEY}`;
|
||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||
|
||||
|
||||
26
backend/helpers/teams.js
Normal file
26
backend/helpers/teams.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Shared BU team constants and validation
|
||||
// Used by user management routes, auth middleware, and frontend-facing endpoints.
|
||||
|
||||
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||
|
||||
/**
|
||||
* Parse and validate a comma-separated teams string.
|
||||
* @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {{ valid: boolean, teams: string[], invalid: string[] }}
|
||||
*/
|
||||
function validateTeams(teamsString) {
|
||||
if (!teamsString || typeof teamsString !== 'string' || teamsString.trim() === '') {
|
||||
return { valid: true, teams: [], invalid: [] };
|
||||
}
|
||||
|
||||
const teams = teamsString.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const invalid = teams.filter(t => !KNOWN_TEAMS.includes(t));
|
||||
|
||||
return {
|
||||
valid: invalid.length === 0,
|
||||
teams,
|
||||
invalid
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { KNOWN_TEAMS, validateTeams };
|
||||
@@ -12,7 +12,7 @@ function requireAuth(db) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -38,7 +38,8 @@ function requireAuth(db) {
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
role: session.role,
|
||||
group: session.user_group
|
||||
group: session.user_group,
|
||||
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||
};
|
||||
|
||||
next();
|
||||
|
||||
68
backend/migrations/add_user_bu_teams.js
Normal file
68
backend/migrations/add_user_bu_teams.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Migration: Add bu_teams column to users table
|
||||
// Stores comma-separated BU team identifiers per user (e.g. 'STEAM,ACCESS-ENG')
|
||||
// Existing users get empty string (admin must assign teams post-migration)
|
||||
// Idempotent — safe to run multiple times
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const DB_FILE = path.join(__dirname, '..', 'cve_database.db');
|
||||
|
||||
/**
|
||||
* Run the migration against the given database instance.
|
||||
* Exported for testing with in-memory databases.
|
||||
* @param {sqlite3.Database} db
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function runMigration(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
// Check if bu_teams column already exists
|
||||
db.all("PRAGMA table_info(users)", (err, columns) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBuTeams = columns.some(col => col.name === 'bu_teams');
|
||||
|
||||
if (hasBuTeams) {
|
||||
console.log('✓ bu_teams column already exists — skipping migration');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Adding bu_teams column to users table...');
|
||||
|
||||
db.run(
|
||||
`ALTER TABLE users ADD COLUMN bu_teams TEXT NOT NULL DEFAULT ''`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log('✓ Added bu_teams column (default: empty string)');
|
||||
console.log(' Note: Admin must assign teams to existing users via user management UI');
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run directly if executed as a script
|
||||
if (require.main === module) {
|
||||
const db = new sqlite3.Database(DB_FILE);
|
||||
runMigration(db)
|
||||
.then(() => {
|
||||
console.log('Migration complete.');
|
||||
db.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err.message);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runMigration };
|
||||
@@ -7,9 +7,19 @@ const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// Diagnostic log helper — writes to atlas-sync-debug.log in the backend folder
|
||||
function syncLog(msg) {
|
||||
const line = `${new Date().toISOString()} ${msg}\n`;
|
||||
try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,7 +139,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache`
|
||||
`SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
@@ -227,7 +237,34 @@ function createAtlasRouter(db, requireAuth) {
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0 ? 1 : 0;
|
||||
|
||||
console.log(`[Atlas Sync] Host ${hostId}: status=${result.status}, activePlans=${activePlans.length}, allPlans=${allPlans.length}, hasActionPlan=${hasActionPlan}`);
|
||||
|
||||
try {
|
||||
// If Atlas returns 0 plans but we have a recent optimistic
|
||||
// entry (from bulk creation within the last 10 minutes),
|
||||
// keep the optimistic value — Atlas's GET may lag behind.
|
||||
if (hasActionPlan === 0) {
|
||||
const existing = await dbGet(db,
|
||||
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = ?`,
|
||||
[hostId]
|
||||
);
|
||||
if (existing && existing.has_action_plan === 1) {
|
||||
let existingPlans = [];
|
||||
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
|
||||
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
|
||||
if (hasBulkStub) {
|
||||
const ageMs = Date.now() - new Date(existing.synced_at + 'Z').getTime();
|
||||
const TEN_MINUTES = 10 * 60 * 1000;
|
||||
if (ageMs < TEN_MINUTES) {
|
||||
console.log(`[Atlas Sync] Host ${hostId}: keeping optimistic bulk-create entry (${Math.round(ageMs / 1000)}s old)`);
|
||||
synced++;
|
||||
withPlans++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
@@ -246,7 +283,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
if (hasActionPlan) withPlans++;
|
||||
} else {
|
||||
failed++;
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}, body=${result.body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,6 +543,52 @@ function createAtlasRouter(db, requireAuth) {
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
|
||||
// Optimistically update local cache for all submitted hosts.
|
||||
// Atlas's individual GET endpoint may lag behind the bulk
|
||||
// creation, so we mark every host as having a plan now rather
|
||||
// than waiting for the next sync to discover it.
|
||||
for (const hid of host_ids) {
|
||||
try {
|
||||
const existing = await dbGet(db,
|
||||
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = ?`,
|
||||
[hid]
|
||||
);
|
||||
|
||||
let existingPlans = [];
|
||||
if (existing && existing.plans_json) {
|
||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
||||
const updatedPlans = [...existingPlans, stubPlan];
|
||||
const newCount = updatedPlans.length;
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, 1, ?, ?, datetime('now'))
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = 1,
|
||||
plan_count = excluded.plan_count,
|
||||
plans_json = excluded.plans_json,
|
||||
synced_at = excluded.synced_at`,
|
||||
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||
);
|
||||
} catch (cacheErr) {
|
||||
console.error('[Atlas] Cache update failed for host', hid, ':', cacheErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_BULK_CREATE_PLANS',
|
||||
entityType: 'atlas_action_plan',
|
||||
entityId: null,
|
||||
details: { host_ids, plan_type, commit_date, count: host_ids.length },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
|
||||
@@ -143,7 +143,8 @@ function createAuthRouter(db, logAudit) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
group: user.user_group
|
||||
group: user.user_group,
|
||||
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -222,7 +223,7 @@ function createAuthRouter(db, logAudit) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -249,7 +250,8 @@ function createAuthRouter(db, logAudit) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
group: session.user_group
|
||||
group: session.user_group,
|
||||
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
121
backend/routes/feedback.js
Normal file
121
backend/routes/feedback.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
|
||||
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function createFeedbackRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
const GITLAB_URL = process.env.GITLAB_URL || '';
|
||||
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
||||
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
||||
|
||||
/**
|
||||
* POST /api/feedback
|
||||
*
|
||||
* Create a GitLab issue from a bug report or feature request.
|
||||
* Available to all authenticated users.
|
||||
*
|
||||
* @body {string} type - "bug" or "feature"
|
||||
* @body {string} title - Issue title
|
||||
* @body {string} description - Issue description
|
||||
* @body {string} [page] - Which dashboard page the user was on
|
||||
*/
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
||||
return res.status(503).json({ error: 'Feedback integration not configured' });
|
||||
}
|
||||
|
||||
const { type, title, description, page } = req.body;
|
||||
|
||||
if (!type || !title || !description) {
|
||||
return res.status(400).json({ error: 'type, title, and description are required' });
|
||||
}
|
||||
|
||||
if (!['bug', 'feature'].includes(type)) {
|
||||
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
|
||||
}
|
||||
|
||||
const labels = type === 'bug' ? 'bug' : 'enhancement';
|
||||
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
||||
const username = req.user?.username || 'unknown';
|
||||
|
||||
const body = [
|
||||
`**Submitted by:** ${username}`,
|
||||
page ? `**Page:** ${page}` : null,
|
||||
`**Type:** ${prefix}`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
description,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const postData = JSON.stringify({
|
||||
title: `[${prefix}] ${title}`,
|
||||
description: body,
|
||||
labels,
|
||||
});
|
||||
|
||||
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const parsed = new URL(apiUrl);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const reqOpts = {
|
||||
method: 'POST',
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'PRIVATE-TOKEN': GITLAB_PAT,
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
rejectAuthorized: false,
|
||||
};
|
||||
|
||||
const apiReq = transport.request(reqOpts, (apiRes) => {
|
||||
let data = '';
|
||||
apiRes.on('data', chunk => data += chunk);
|
||||
apiRes.on('end', () => {
|
||||
try {
|
||||
resolve({ status: apiRes.statusCode, body: JSON.parse(data) });
|
||||
} catch {
|
||||
resolve({ status: apiRes.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
apiReq.on('error', reject);
|
||||
apiReq.write(postData);
|
||||
apiReq.end();
|
||||
});
|
||||
|
||||
if (result.status === 201) {
|
||||
console.log(`[Feedback] Issue #${result.body.iid} created by ${username}: ${title}`);
|
||||
res.json({
|
||||
success: true,
|
||||
issue: {
|
||||
id: result.body.iid,
|
||||
url: result.body.web_url,
|
||||
title: result.body.title,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(`[Feedback] GitLab API returned ${result.status}:`, result.body);
|
||||
res.status(502).json({ error: 'GitLab API error', details: result.body });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Feedback] Request failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to connect to GitLab' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createFeedbackRouter;
|
||||
@@ -8,6 +8,10 @@ const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Configurable BU filter — broadened via env var to support multi-tenancy.
|
||||
// Users see only their assigned teams' findings (filtered at query time).
|
||||
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
||||
|
||||
const FINDINGS_FILTERS = [
|
||||
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||
{
|
||||
@@ -16,7 +20,7 @@ const FINDINGS_FILTERS = [
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
value: BU_FILTER_VALUE,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
@@ -47,7 +51,7 @@ const CLOSED_COUNT_FILTERS = [
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
value: BU_FILTER_VALUE,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
@@ -1118,13 +1122,30 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
* GET /api/ivanti/findings
|
||||
*
|
||||
* Return cached Ivanti findings with notes and overrides merged in.
|
||||
* Accepts optional `teams` query parameter (comma-separated) to filter
|
||||
* findings by buOwnership. If omitted, returns all findings.
|
||||
*
|
||||
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
|
||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object, total: number }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
const state = await readStateWithNotes(db);
|
||||
|
||||
// Filter by teams if provided
|
||||
const teamsParam = req.query.teams;
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
state.findings = state.findings.filter(f =>
|
||||
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
|
||||
);
|
||||
state.total = state.findings.length;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(state);
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading findings' });
|
||||
}
|
||||
@@ -1152,13 +1173,32 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
* GET /api/ivanti/findings/counts
|
||||
*
|
||||
* Return open vs closed finding totals for the pie chart.
|
||||
* Accepts optional `teams` query parameter to scope the open count
|
||||
* to specific BUs. Closed count remains global (approximate) when filtered.
|
||||
*
|
||||
* @returns {Object} 200 - { open: number, closed: number }
|
||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {Object} 200 - { open: number, closed: number, filtered: boolean }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/counts', async (req, res) => {
|
||||
try {
|
||||
res.json(await readCounts(db));
|
||||
const teamsParam = req.query.teams;
|
||||
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
// For open count, filter the cached findings by team
|
||||
const state = await readState(db);
|
||||
const filtered = state.findings.filter(f =>
|
||||
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
|
||||
);
|
||||
// Closed count is global — we don't store per-finding closed data
|
||||
const counts = await readCounts(db);
|
||||
return res.json({ open: filtered.length, closed: counts.closed, filtered: true });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...(await readCounts(db)), filtered: false });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading counts' });
|
||||
}
|
||||
|
||||
@@ -132,69 +132,6 @@ function createJiraTicketsRouter(db) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/search
|
||||
*
|
||||
* Search Jira issues using a JQL query. Results are capped at 1000 per page.
|
||||
* Charter compliance: JQL must include project+updated, assignee+updated,
|
||||
* or status+updated. Fields are always specified explicitly.
|
||||
*
|
||||
* @body {string} jql - JQL query string (required, max 2000 chars)
|
||||
* @body {number} [startAt] - Pagination offset
|
||||
* @body {number} [maxResults] - Page size (max 1000)
|
||||
* @body {string[]} [fields] - Explicit field list for the Jira response
|
||||
* @returns {object} 200 - { total, startAt, maxResults, issues: [{ key, summary, status, assignee, priority, issuetype, created, updated }] }
|
||||
* @returns {object} 400 - { error } when JQL is missing or too long
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira search failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/search', requireAuth(db), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { jql, startAt, maxResults, fields } = req.body;
|
||||
if (!jql || typeof jql !== 'string' || jql.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'JQL query is required.' });
|
||||
}
|
||||
if (jql.length > 2000) {
|
||||
return res.status(400).json({ error: 'JQL query too long (max 2000 chars).' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.searchIssues(jql, {
|
||||
startAt,
|
||||
maxResults: Math.min(maxResults || 1000, 1000),
|
||||
fields: fields || undefined
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data;
|
||||
return res.json({
|
||||
total: data.total,
|
||||
startAt: data.startAt,
|
||||
maxResults: data.maxResults,
|
||||
issues: (data.issues || []).map(issue => ({
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status ? issue.fields.status.name : null,
|
||||
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
|
||||
priority: issue.fields.priority ? issue.fields.priority.name : null,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
|
||||
created: issue.fields.created,
|
||||
updated: issue.fields.updated
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Jira search failed.', details: result.body });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/create-in-jira
|
||||
*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// User Management Routes (Admin only)
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { validateTeams } = require('../helpers/teams');
|
||||
|
||||
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
const router = express.Router();
|
||||
@@ -13,7 +14,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
try {
|
||||
const users = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
@@ -21,7 +22,12 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
}
|
||||
);
|
||||
});
|
||||
res.json(users);
|
||||
// Parse bu_teams into teams array for each user
|
||||
const usersWithTeams = users.map(u => ({
|
||||
...u,
|
||||
teams: u.bu_teams ? u.bu_teams.split(',').filter(Boolean) : []
|
||||
}));
|
||||
res.json(usersWithTeams);
|
||||
} catch (err) {
|
||||
console.error('Get users error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
@@ -33,7 +39,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login
|
||||
FROM users WHERE id = ?`,
|
||||
[req.params.id],
|
||||
(err, row) => {
|
||||
@@ -47,7 +53,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
res.json({
|
||||
...user,
|
||||
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get user error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch user' });
|
||||
@@ -56,7 +65,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
// Create new user
|
||||
router.post('/', async (req, res) => {
|
||||
const { username, email, password, group } = req.body;
|
||||
const { username, email, password, group, bu_teams } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
|
||||
if (!username || !email || !password) {
|
||||
@@ -69,14 +78,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||
}
|
||||
|
||||
// Validate bu_teams if provided
|
||||
const teamsStr = bu_teams || '';
|
||||
if (teamsStr) {
|
||||
const teamsResult = validateTeams(teamsStr);
|
||||
if (!teamsResult.valid) {
|
||||
return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, user_group)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[username, email, passwordHash, userGroup],
|
||||
`INSERT INTO users (username, email, password_hash, user_group, bu_teams)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[username, email, passwordHash, userGroup, teamsStr],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID });
|
||||
@@ -90,7 +108,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
action: 'user_create',
|
||||
entityType: 'user',
|
||||
entityId: String(result.id),
|
||||
details: { created_username: username, group: userGroup },
|
||||
details: { created_username: username, group: userGroup, bu_teams: teamsStr },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -100,7 +118,9 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
id: result.id,
|
||||
username,
|
||||
email,
|
||||
group: userGroup
|
||||
group: userGroup,
|
||||
bu_teams: teamsStr,
|
||||
teams: teamsStr ? teamsStr.split(',').filter(Boolean) : []
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -114,7 +134,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
// Update user
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { username, email, password, group, is_active } = req.body;
|
||||
const { username, email, password, group, is_active, bu_teams } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
const userId = req.params.id;
|
||||
|
||||
@@ -133,11 +153,21 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
return res.status(400).json({ error: 'Cannot deactivate your own account' });
|
||||
}
|
||||
|
||||
// Validate bu_teams if provided
|
||||
if (typeof bu_teams === 'string') {
|
||||
if (bu_teams !== '') {
|
||||
const teamsResult = validateTeams(bu_teams);
|
||||
if (!teamsResult.valid) {
|
||||
return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch current user record before update (needed for group change audit)
|
||||
const currentUser = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT user_group FROM users WHERE id = ?',
|
||||
'SELECT user_group, bu_teams FROM users WHERE id = ?',
|
||||
[userId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
@@ -174,6 +204,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
updates.push('is_active = ?');
|
||||
values.push(is_active ? 1 : 0);
|
||||
}
|
||||
if (typeof bu_teams === 'string') {
|
||||
updates.push('bu_teams = ?');
|
||||
values.push(bu_teams);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
@@ -198,6 +232,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
if (group) updatedFields.group = group;
|
||||
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||
if (password) updatedFields.password_changed = true;
|
||||
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
@@ -225,6 +260,22 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
});
|
||||
}
|
||||
|
||||
// Log specific audit entry for bu_teams changes
|
||||
if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_teams_change',
|
||||
entityType: 'user',
|
||||
entityId: String(userId),
|
||||
details: {
|
||||
previous_teams: currentUser.bu_teams || '',
|
||||
new_teams: bu_teams
|
||||
},
|
||||
ipAddress: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
// If user was deactivated, delete their sessions
|
||||
if (is_active === false) {
|
||||
await new Promise((resolve) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const { createComplianceRouter } = require('./routes/compliance');
|
||||
const createAtlasRouter = require('./routes/atlas');
|
||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||
const createCardApiRouter = require('./routes/cardApi');
|
||||
const createFeedbackRouter = require('./routes/feedback');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -246,6 +247,9 @@ app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
|
||||
app.use('/api/card', createCardApiRouter(db, requireAuth));
|
||||
|
||||
// Feedback routes — bug reports and feature requests to GitLab
|
||||
app.use('/api/feedback', createFeedbackRouter(db, requireAuth));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
@@ -114,6 +114,7 @@ async function initializeDatabase(db) {
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only',
|
||||
bu_teams TEXT NOT NULL DEFAULT '',
|
||||
CHECK (role IN ('admin', 'editor', 'viewer'))
|
||||
);
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ All API calls are made from a single Node.js backend process. The integration us
|
||||
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
|
||||
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
|
||||
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
|
||||
| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with JQL query parameters, not per-issue GETs |
|
||||
| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with predefined key-based JQL query parameters, not per-issue GETs; no arbitrary JQL passthrough |
|
||||
| Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=<KEY>&fields=...&maxResults=1` |
|
||||
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause and `project = <KEY>` scoping |
|
||||
| JQL scoping | All recurring JQL queries use predefined scoped patterns with `updated >= -72h` clause and `project = <KEY>` scoping; no arbitrary JQL passthrough |
|
||||
| `maxResults` cap | Search queries capped at 1 000 results per page |
|
||||
|
||||
---
|
||||
@@ -96,7 +96,7 @@ All API calls are made from a single Node.js backend process. The integration us
|
||||
| **Frequency** | Manual, estimated 5–10 per day |
|
||||
| **Purpose** | Move ticket through workflow states (e.g., Open to In Progress to Closed) |
|
||||
|
||||
### 8. JQL Search (Bulk Sync)
|
||||
### 8. Scoped Bulk Sync via JQL
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -104,10 +104,10 @@ All API calls are made from a single Node.js backend process. The integration us
|
||||
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
|
||||
| **Frequency** | Manual, estimated 1–3 times per day |
|
||||
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
|
||||
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h AND project = <KEY>` |
|
||||
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -72h AND project = <KEY>` |
|
||||
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
|
||||
| **Batch size** | 100 keys per JQL query; multiple batches if needed |
|
||||
| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
|
||||
| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. JQL is predefined and scoped — constructed from known tracked issue keys, a fixed 72-hour window, and the configured project key. No arbitrary JQL is accepted from the frontend. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
|
||||
|
||||
### 9. Issue Lookup
|
||||
|
||||
@@ -131,7 +131,7 @@ All API calls are made from a single Node.js backend process. The integration us
|
||||
| Add comment | 5–15 | POST | 2s |
|
||||
| Get transitions | 5–10 | GET | 1s |
|
||||
| Transition issue | 5–10 | POST | 2s |
|
||||
| JQL search (sync) | 1–5 | GET | 1s |
|
||||
| Scoped bulk sync | 1–5 | GET | 1s |
|
||||
| Issue lookup | 5–15 | GET | 1s |
|
||||
| **Total estimated** | **43–120** | | |
|
||||
|
||||
@@ -145,6 +145,7 @@ The integration explicitly blocks these endpoints to comply with Charter policy:
|
||||
|
||||
- `/rest/api/2/field` — field metadata is never queried; fields are specified in code
|
||||
- `/rest/api/2/issue/bulk` — bulk updates are not used; issues are updated individually
|
||||
- `POST /rest/api/2/search` — arbitrary JQL search via POST is not used; all searches use `GET /rest/api/2/search` with URL-encoded query parameters and predefined scoped JQL patterns
|
||||
|
||||
---
|
||||
|
||||
|
||||
61
frontend/src/components/AdminScopeToggle.js
Normal file
61
frontend/src/components/AdminScopeToggle.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// AdminScopeToggle.js
|
||||
// Two-state toggle for Admin users: "My Teams" vs "All BUs"
|
||||
// Controls whether data on Reporting, Compliance, and Exports pages
|
||||
// is scoped to the admin's assigned teams or shows everything.
|
||||
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
function AdminScopeToggle() {
|
||||
const { isAdmin, adminScope, toggleAdminScope, hasTeams } = useAuth();
|
||||
|
||||
// Only render for Admin users who have teams assigned
|
||||
// (if no teams assigned, both modes are identical — no toggle needed)
|
||||
if (!isAdmin() || !hasTeams()) return null;
|
||||
|
||||
const isAllMode = adminScope === 'all';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(14, 165, 233, 0.05)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'monospace',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
|
||||
<button
|
||||
onClick={toggleAdminScope}
|
||||
aria-label={`Switch to ${isAllMode ? 'My Teams' : 'All BUs'} view`}
|
||||
aria-pressed={isAllMode}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.02em',
|
||||
transition: 'all 0.15s ease',
|
||||
background: isAllMode ? 'rgba(139, 92, 246, 0.15)' : 'rgba(14, 165, 233, 0.15)',
|
||||
color: isAllMode ? '#8B5CF6' : '#0EA5E9',
|
||||
}}
|
||||
>
|
||||
{isAllMode ? '⊕ All BUs' : '⊙ My Teams'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminScopeToggle;
|
||||
@@ -510,6 +510,36 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
|
||||
}
|
||||
const data = await res.json();
|
||||
const remotePlans = parseAtlasPlans(data);
|
||||
|
||||
// If Atlas returns no plans, check local cache for optimistic bulk-create stubs
|
||||
if (remotePlans.length === 0) {
|
||||
try {
|
||||
const cacheRes = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
|
||||
if (cacheRes.ok) {
|
||||
const cacheData = await cacheRes.json();
|
||||
const hostCache = cacheData.find(r => r.host_id === hostId);
|
||||
if (hostCache && hostCache.has_action_plan === 1 && hostCache.plans_json) {
|
||||
let cachedPlans = [];
|
||||
try { cachedPlans = typeof hostCache.plans_json === 'string' ? JSON.parse(hostCache.plans_json) : hostCache.plans_json; } catch (_) {}
|
||||
const stubs = cachedPlans
|
||||
.filter(p => p.source === 'bulk-create')
|
||||
.map((p, i) => ({
|
||||
action_plan_id: 'pending-' + hostId + '-' + i,
|
||||
plan_type: p.plan_type || 'unknown',
|
||||
commit_date: p.commit_date || '',
|
||||
status: 'pending',
|
||||
_localPending: true,
|
||||
created_at: p.created_at || '',
|
||||
}));
|
||||
if (stubs.length > 0) {
|
||||
setPlans(stubs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore cache fallback errors */ }
|
||||
}
|
||||
|
||||
// Merge: keep local pending plans that aren't yet confirmed by Atlas
|
||||
setPlans(prev => {
|
||||
const localPending = prev.filter(p => p._localPending);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AdminScopeToggle from './AdminScopeToggle';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
@@ -63,6 +64,12 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
|
||||
{/* Admin scope toggle — between header and nav items */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<AdminScopeToggle />
|
||||
</div>
|
||||
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
|
||||
@@ -180,7 +180,8 @@ export default function UserManagement({ onClose }) {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
group: 'Read_Only'
|
||||
group: 'Read_Only',
|
||||
bu_teams: ''
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
@@ -240,7 +241,7 @@ export default function UserManagement({ onClose }) {
|
||||
setTimeout(() => {
|
||||
setShowAddUser(false);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
|
||||
setFormSuccess('');
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
@@ -278,7 +279,8 @@ export default function UserManagement({ onClose }) {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
group: user.group
|
||||
group: user.group,
|
||||
bu_teams: user.bu_teams || ''
|
||||
});
|
||||
setShowAddUser(true);
|
||||
setFormError('');
|
||||
@@ -361,7 +363,7 @@ export default function UserManagement({ onClose }) {
|
||||
onClick={() => {
|
||||
setShowAddUser(true);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
}}
|
||||
@@ -482,6 +484,50 @@ export default function UserManagement({ onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BU Teams assignment */}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<label style={styles.label}>BU Teams</label>
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
background: 'rgba(30,41,59,0.6)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.5rem',
|
||||
}}>
|
||||
{['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'].map(team => {
|
||||
const currentTeams = formData.bu_teams ? formData.bu_teams.split(',').filter(Boolean) : [];
|
||||
const isChecked = currentTeams.includes(team);
|
||||
return (
|
||||
<label key={team} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||
cursor: 'pointer', fontSize: '0.8rem', fontFamily: 'monospace',
|
||||
color: isChecked ? '#38BDF8' : '#94A3B8',
|
||||
padding: '0.25rem 0.5rem', borderRadius: '0.25rem',
|
||||
background: isChecked ? 'rgba(14,165,233,0.1)' : 'transparent',
|
||||
border: isChecked ? '1px solid rgba(14,165,233,0.3)' : '1px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {
|
||||
const updated = isChecked
|
||||
? currentTeams.filter(t => t !== team)
|
||||
: [...currentTeams, team];
|
||||
setFormData({ ...formData, bu_teams: updated.join(',') });
|
||||
}}
|
||||
style={{ accentColor: '#0EA5E9' }}
|
||||
/>
|
||||
{team}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.65rem', color: '#64748B', marginTop: '0.375rem' }}>
|
||||
Determines which BU data the user sees on Reporting and Compliance pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
|
||||
<button type="submit" style={styles.primaryBtn}
|
||||
onMouseEnter={e => {
|
||||
@@ -523,6 +569,7 @@ export default function UserManagement({ onClose }) {
|
||||
<tr>
|
||||
<th style={styles.th}>User</th>
|
||||
<th style={styles.th}>Group</th>
|
||||
<th style={styles.th}>Teams</th>
|
||||
<th style={styles.th}>Status</th>
|
||||
<th style={styles.th}>Last Login</th>
|
||||
<th style={styles.thRight}>Actions</th>
|
||||
@@ -547,6 +594,25 @@ export default function UserManagement({ onClose }) {
|
||||
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
{(user.teams && user.teams.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{user.teams.map(t => (
|
||||
<span key={t} style={{
|
||||
fontSize: '0.65rem', fontFamily: 'monospace',
|
||||
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
|
||||
background: 'rgba(14,165,233,0.1)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
color: '#7DD3FC',
|
||||
}}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.7rem', color: '#F59E0B', fontStyle: 'italic' }}>
|
||||
⚠ No teams
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<button
|
||||
onClick={() => handleToggleActive(user)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const TEAMS = ['STEAM', 'ACCESS-ENG'];
|
||||
|
||||
// Build definitions lookup map once at module level
|
||||
const METRIC_DEFINITIONS = {};
|
||||
@@ -246,9 +245,10 @@ function SeenBadge({ count }) {
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CompliancePage({ onNavigate }) {
|
||||
const { canWrite, isAdmin } = useAuth();
|
||||
const { canWrite, isAdmin, getAvailableTeams, adminScope } = useAuth();
|
||||
|
||||
const [activeTeam, setActiveTeam] = useState('STEAM');
|
||||
const availableTeams = getAvailableTeams();
|
||||
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
@@ -298,6 +298,14 @@ export default function CompliancePage({ onNavigate }) {
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When admin scope changes, reset to first available team
|
||||
useEffect(() => {
|
||||
const teams = getAvailableTeams();
|
||||
if (teams.length > 0 && !teams.includes(activeTeam)) {
|
||||
setActiveTeam(teams[0]);
|
||||
}
|
||||
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
@@ -419,8 +427,19 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
|
||||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||
{availableTeams.length === 0 && !isAdmin() ? (
|
||||
<div style={{
|
||||
padding: '1.5rem', marginBottom: '1.5rem',
|
||||
borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
background: 'rgba(245, 158, 11, 0.05)',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', color: '#F59E0B',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
No BU teams assigned to your account. Contact an admin to configure your team access.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||
{TEAMS.map(team => {
|
||||
{availableTeams.map(team => {
|
||||
const isActive = activeTeam === team;
|
||||
return (
|
||||
<button key={team} onClick={() => setActiveTeam(team)}
|
||||
@@ -441,6 +460,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{families.length > 0 ? (
|
||||
|
||||
@@ -97,8 +97,11 @@ function findingRow(f) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// API fetchers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchFindings() {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
async function fetchFindings(teamsParam) {
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/ivanti/findings`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.findings || [];
|
||||
@@ -129,8 +132,8 @@ async function fetchAtlasStatus() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchAtlasAndFindings() {
|
||||
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings()]);
|
||||
async function fetchAtlasAndFindings(teamsParam) {
|
||||
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
|
||||
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
|
||||
const hostMap = {};
|
||||
findings.forEach(f => {
|
||||
@@ -244,7 +247,8 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ExportsPage() {
|
||||
const { canExport } = useAuth();
|
||||
const { canExport, getActiveTeamsParam } = useAuth();
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const [loading, setLoading] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [cveStatus, setCveStatus] = useState('');
|
||||
@@ -266,32 +270,35 @@ export default function ExportsPage() {
|
||||
// ---- Card 1: Ivanti Findings ----
|
||||
|
||||
const exportFullFindings = () => run('ivanti-full', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const scopeLabel = teamsParam || 'ALL';
|
||||
toXLSX(
|
||||
[FINDING_HEADERS, ...findings.map(findingRow)],
|
||||
'All Findings',
|
||||
`findings-full-${dateStr()}.xlsx`,
|
||||
`findings-full-${scopeLabel}-${dateStr()}.xlsx`,
|
||||
);
|
||||
});
|
||||
|
||||
const exportPending = () => run('ivanti-pending', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const scopeLabel = teamsParam || 'ALL';
|
||||
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportOverdue = () => run('ivanti-overdue', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const scopeLabel = teamsParam || 'ALL';
|
||||
const today = dateStr();
|
||||
const rows = findings.filter(f => {
|
||||
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
|
||||
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
|
||||
}).map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportByBU = () => run('ivanti-bu', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const groups = {};
|
||||
findings.forEach(f => {
|
||||
const bu = f.buOwnership || 'Unknown';
|
||||
@@ -308,7 +315,7 @@ export default function ExportsPage() {
|
||||
// ---- Card 2: FP Workflow Summary ----
|
||||
|
||||
const exportFPSummary = () => run('fp-summary', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const fpMap = {};
|
||||
findings.forEach(f => {
|
||||
if (!f.workflow?.id) return;
|
||||
@@ -383,20 +390,20 @@ export default function ExportsPage() {
|
||||
}
|
||||
|
||||
const exportAtlasStatus = () => run('atlas-status', async () => {
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
|
||||
const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id]));
|
||||
toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportAtlasGaps = () => run('atlas-gaps', async () => {
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
|
||||
const gaps = atlasRows.filter(a => !a.has_action_plan);
|
||||
const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id]));
|
||||
toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportAtlasFull = () => run('atlas-full', async () => {
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
|
||||
const withPlans = atlasRows.filter(a => a.has_action_plan);
|
||||
const withoutPlans = atlasRows.filter(a => !a.has_action_plan);
|
||||
const sheets = [
|
||||
|
||||
@@ -4492,7 +4492,11 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
const seen = new Map();
|
||||
for (const f of selectedFindings) {
|
||||
if (f.hostId && !seen.has(f.hostId)) {
|
||||
seen.set(f.hostId, { hostId: f.hostId, hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId) });
|
||||
seen.set(f.hostId, {
|
||||
hostId: f.hostId,
|
||||
hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId),
|
||||
findingId: f.id ? Number(f.id) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
@@ -4575,7 +4579,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
if (!commitDate) { setError('Commit date is required'); return; }
|
||||
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
|
||||
const needsQualys = NEEDS_QUALYS.has(planType);
|
||||
if (needsQualys && selectedQualys.size === 0) {
|
||||
if (needsQualys && selectedQualys.size === 0 && availableQualys.length > 0) {
|
||||
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
|
||||
return;
|
||||
}
|
||||
@@ -4583,12 +4587,19 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const qualysIds = needsQualys ? [...selectedQualys] : [null];
|
||||
// If qualys IDs are selected, iterate per-qualys; otherwise send one request without qualys_id
|
||||
const qualysIds = (needsQualys && selectedQualys.size > 0) ? [...selectedQualys] : [null];
|
||||
const results = [];
|
||||
|
||||
for (const qid of qualysIds) {
|
||||
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
|
||||
if (qid) body.qualys_id = qid;
|
||||
// When no qualys_id is available, include the first finding ID per host
|
||||
// so Atlas can associate the plan with a specific vulnerability
|
||||
if (!qid && needsQualys) {
|
||||
const firstWithFinding = hostEntries.find(h => h.findingId);
|
||||
if (firstWithFinding) body.active_host_findings_id = firstWithFinding.findingId;
|
||||
}
|
||||
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
|
||||
if (archerExc.trim()) body.archer_exc = archerExc.trim();
|
||||
|
||||
@@ -4796,7 +4807,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
|
||||
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
|
||||
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
|
||||
No vulnerabilities found in Atlas for these hosts
|
||||
No vulnerabilities found in Atlas for these hosts — Qualys ID will be omitted
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4908,7 +4919,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const { canWrite } = useAuth();
|
||||
const { canWrite, getActiveTeamsParam, hasTeams, isAdmin, adminScope } = useAuth();
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
@@ -5041,7 +5052,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const fetchCounts = async () => {
|
||||
setCountsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/ivanti/findings/counts`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
|
||||
} catch (e) {
|
||||
@@ -5127,7 +5142,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/ivanti/findings`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
applyState(data);
|
||||
@@ -5169,6 +5188,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
fetchCardStatus();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Re-fetch findings and counts when admin scope toggle changes
|
||||
useEffect(() => {
|
||||
fetchFindings();
|
||||
fetchCounts();
|
||||
}, [adminScope]); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
const setColFilter = useCallback((colKey, vals) => {
|
||||
setColumnFilters((prev) => {
|
||||
|
||||
@@ -2,6 +2,9 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// Known BU teams — must match backend helpers/teams.js
|
||||
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
@@ -9,6 +12,11 @@ export function AuthProvider({ children }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Admin scope toggle — persisted in localStorage
|
||||
const [adminScope, setAdminScope] = useState(
|
||||
() => localStorage.getItem('admin_bu_scope') || 'my-teams'
|
||||
);
|
||||
|
||||
// Check if user is authenticated on mount
|
||||
const checkAuth = useCallback(async () => {
|
||||
try {
|
||||
@@ -93,6 +101,46 @@ export function AuthProvider({ children }) {
|
||||
// Check if user is admin
|
||||
const isAdmin = () => isInGroup('Admin');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Multi-BU tenancy helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Whether the user has any BU teams assigned
|
||||
const hasTeams = () => (user?.teams?.length ?? 0) > 0;
|
||||
|
||||
// Whether the user is a member of a specific team (or is Admin in "All BUs" mode)
|
||||
const isTeamMember = (team) => {
|
||||
if (!user) return false;
|
||||
if (isInGroup('Admin') && adminScope === 'all') return true;
|
||||
return (user.teams || []).includes(team);
|
||||
};
|
||||
|
||||
// Toggle admin scope between 'my-teams' and 'all'
|
||||
const toggleAdminScope = () => {
|
||||
setAdminScope(prev => {
|
||||
const next = prev === 'my-teams' ? 'all' : 'my-teams';
|
||||
localStorage.setItem('admin_bu_scope', next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Returns the comma-joined teams string for API query params.
|
||||
// Empty string means "no filter" (show all).
|
||||
const getActiveTeamsParam = () => {
|
||||
if (!user) return '';
|
||||
if (isInGroup('Admin') && adminScope === 'all') return '';
|
||||
const teams = user.teams || [];
|
||||
return teams.join(',');
|
||||
};
|
||||
|
||||
// Returns the list of teams available for UI selectors (compliance team picker, etc.)
|
||||
// Admin in "All BUs" mode sees all known teams; otherwise scoped to user's teams.
|
||||
const getAvailableTeams = () => {
|
||||
if (!user) return [];
|
||||
if (isInGroup('Admin') && adminScope === 'all') return KNOWN_TEAMS;
|
||||
return user.teams || [];
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
@@ -105,7 +153,15 @@ export function AuthProvider({ children }) {
|
||||
canDelete,
|
||||
canExport,
|
||||
isAdmin,
|
||||
isAuthenticated: !!user
|
||||
isAuthenticated: !!user,
|
||||
// Multi-BU tenancy
|
||||
hasTeams,
|
||||
isTeamMember,
|
||||
adminScope,
|
||||
toggleAdminScope,
|
||||
getActiveTeamsParam,
|
||||
getAvailableTeams,
|
||||
KNOWN_TEAMS,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user