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:
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user