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

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