11 KiB
Design Document: Multi-BU Tenancy (Option B)
Overview
This design adds per-user BU team scoping to the existing single-tenant dashboard. The approach uses a single Ivanti API key with a broadened sync filter, stores all findings in the existing cache, and filters at query time based on the authenticated user's assigned teams. The compliance page leverages its existing team-partitioned data model — only the frontend team selector needs scoping.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
│ │
│ AuthContext.teams ──► ReportingPage (filter by teams) │
│ ──► CompliancePage (scope team selector) │
│ ──► ExportsPage (scope exports) │
│ ──► CVE HomePage (NO filtering) │
│ │
└───────────────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend API │
│ │
│ GET /api/auth/me ──► returns { ...user, teams: ['STEAM',...] } │
│ GET /api/ivanti/findings?teams=STEAM,ACCESS-ENG │
│ GET /api/ivanti/findings/counts?teams=STEAM │
│ GET /api/compliance/items?team=STEAM (unchanged) │
│ │
│ Ivanti Sync: uses IVANTI_BU_FILTER env var (broadened) │
│ │
└───────────────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Database │
│ │
│ users.bu_teams TEXT DEFAULT '' (comma-separated team IDs) │
│ ivanti_findings_cache.findings_json (all BUs, filtered on read) │
│ compliance_items.team (already team-partitioned) │
│ │
└─────────────────────────────────────────────────────────────────┘
Database Changes
Migration: add_user_bu_teams.js
ALTER TABLE users ADD COLUMN bu_teams TEXT NOT NULL DEFAULT '';
- Stores comma-separated team identifiers (e.g.
'STEAM,ACCESS-ENG') - Empty string means no teams assigned (new users start unscoped)
- No foreign key — validated at application layer against known teams list
- Existing users get empty string (admin must assign teams post-migration)
Known Teams Constant
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
Defined once in a shared location (e.g. backend/helpers/teams.js) and imported by routes that need validation.
Backend Changes
1. Auth Middleware (middleware/auth.js)
Update the session query to SELECT bu_teams and parse it onto req.user:
req.user = {
id: session.user_id,
username: session.username,
email: session.email,
group: session.user_group,
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
};
2. Auth Routes (routes/auth.js)
POST /loginresponse includesteamsarrayGET /meresponse includesteamsarray
3. User Management Routes (routes/users.js)
POST /(create user): accepts optionalbu_teamsfield, validates against KNOWN_TEAMSPATCH /:id(update user): acceptsbu_teamsfield, validates, logs change to auditGET /andGET /:id: returnbu_teamsin user records
4. Ivanti Findings Routes (routes/ivantiFindings.js)
Sync filter change:
// Before (hardcoded):
const FINDINGS_FILTERS = [
{ field: 'assetCustomAttributes.1550_host_1.value', value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', ... }
];
// After (configurable):
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
const FINDINGS_FILTERS = [
{ field: 'assetCustomAttributes.1550_host_1.value', value: BU_FILTER_VALUE, ... }
];
Query-time filtering on GET /findings:
router.get('/findings', requireAuth(db), async (req, res) => {
const state = await readState(db);
let findings = state.findings || [];
// Filter by teams if provided
const teamsParam = req.query.teams;
if (teamsParam) {
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase());
findings = findings.filter(f =>
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
);
}
res.json({ findings, total: findings.length, ... });
});
Same pattern for /findings/counts — filter before counting.
5. BU Ownership Matching Strategy
The Ivanti buOwnership field contains full BU names like "NTS-AEO-STEAM" or "NTS-AEO-ACCESS-ENG". The user's bu_teams stores short names like "STEAM" or "ACCESS-ENG". Matching uses a contains/includes check:
// Match: user team "STEAM" matches finding buOwnership "NTS-AEO-STEAM"
const matchesTeam = (buOwnership, userTeams) =>
userTeams.some(t => (buOwnership || '').toUpperCase().includes(t.toUpperCase()));
This is intentionally loose to handle variations in Ivanti's BU naming without requiring exact string equality.
Frontend Changes
1. AuthContext
Add teams to the user state from /api/auth/me:
const [user, setUser] = useState(null);
// user.teams = ['STEAM', 'ACCESS-ENG'] or []
Add helper:
const hasTeams = () => (user?.teams?.length ?? 0) > 0;
const isTeamMember = (team) => user?.teams?.includes(team) || isAdmin();
// Admin scope toggle — persisted in localStorage
const [adminScope, setAdminScope] = useState(
() => localStorage.getItem('admin_bu_scope') || 'my-teams'
);
const toggleAdminScope = () => { /* flip between 'my-teams' and 'all', persist */ };
// Returns the teams param string for API calls based on current scope
const getActiveTeamsParam = () => {
if (isAdmin() && adminScope === 'all') return ''; // no filter
return (user?.teams || []).join(',');
};
2. Reporting Page
- On mount, pass
?teams=${getActiveTeamsParam()}to findings fetch - If
user.teamsis empty and user is not Admin, show "No BU teams assigned" info panel instead of table - Admin users get a scope toggle ("My Teams" / "All BUs") persisted in localStorage
- "My Teams" mode uses the admin's own bu_teams; "All BUs" omits the teams param entirely
- Default on login: "My Teams" so the admin's daily workflow is immediately scoped
- Counts endpoint also receives teams parameter matching current scope mode
3. Compliance Page
- Replace hardcoded
const TEAMS = ['STEAM', 'ACCESS-ENG']with dynamic list from AuthContext - Non-admin: shows only their assigned teams
- Admin in "My Teams" mode: shows only their assigned teams in the selector
- Admin in "All BUs" mode: shows all KNOWN_TEAMS in the selector
- Default
activeTeamto first item in available teams list - If available teams is empty and user is not Admin, show "No teams assigned" message
4. Exports Page
- Pass teams filter when fetching findings for export
- Respects the same scope toggle state as Reporting/Compliance
- Admin in "My Teams" exports only their BUs; "All BUs" exports everything
5. Admin Scope Toggle Component
- Rendered in the top nav/header area, visible only to Admin users
- Two-state toggle: "My Teams" | "All BUs"
- State stored in localStorage key
admin_bu_scope(values:'my-teams'|'all') - Defaults to
'my-teams'if no localStorage value exists - Exposed via AuthContext so all pages can read the current scope without prop drilling
- When admin's bu_teams is empty, both modes behave identically (no filtering)
6. User Management UI
- Add multi-select checkbox group for BU teams in create/edit user forms
- Display assigned teams as badges in user list table
- Show warning icon for users with no teams assigned
Environment Variable
Add to .env.example:
# Ivanti BU Filter — 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: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM,NTS-AEO-INTELDEV,NTS-AEO-ACCESS-OPS
Security Considerations
- Backend compliance endpoints remain open to any valid team parameter (Admin flexibility)
- Reporting endpoint filtering is advisory — an Admin or API caller can omit teams to see all
- The
bu_teamsfield is not a security boundary for compliance data; it's a UX scoping mechanism - True data isolation would require separate databases or row-level security (out of scope for Option B)
- Audit logging captures all team assignment changes
Migration Path
- Run migration to add
bu_teamscolumn (all existing users get empty string) - Admin assigns teams to existing users via user management UI
- Update
IVANTI_BU_FILTERin.envto include all desired BUs - Trigger a manual sync to pull the broader dataset
- Frontend automatically scopes based on user's teams after login
Backward Compatibility
- Users with empty
bu_teamsand Admin group see everything (current behavior preserved) - If
IVANTI_BU_FILTERenv var is not set, defaults to current hardcoded value - Compliance backend endpoints unchanged — only frontend selector is scoped
- CVE page completely unaffected