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:
Jordan Ramos
2026-05-05 11:04:53 -06:00
parent af951fdc12
commit 2656df94d3
24 changed files with 999 additions and 127 deletions

View File

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

View 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 (12 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);
});

View 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);
});
});
});

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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