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

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