# 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` ```sql 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 ```js 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`: ```js 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 /login` response includes `teams` array - `GET /me` response includes `teams` array ### 3. User Management Routes (`routes/users.js`) - `POST /` (create user): accepts optional `bu_teams` field, validates against KNOWN_TEAMS - `PATCH /:id` (update user): accepts `bu_teams` field, validates, logs change to audit - `GET /` and `GET /:id`: return `bu_teams` in user records ### 4. Ivanti Findings Routes (`routes/ivantiFindings.js`) **Sync filter change:** ```js // 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:** ```js 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: ```js // 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`: ```js const [user, setUser] = useState(null); // user.teams = ['STEAM', 'ACCESS-ENG'] or [] ``` Add helper: ```js 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.teams` is 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 `activeTeam` to 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`: ```bash # 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_teams` field 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 1. Run migration to add `bu_teams` column (all existing users get empty string) 2. Admin assigns teams to existing users via user management UI 3. Update `IVANTI_BU_FILTER` in `.env` to include all desired BUs 4. Trigger a manual sync to pull the broader dataset 5. Frontend automatically scopes based on user's teams after login ## Backward Compatibility - Users with empty `bu_teams` and Admin group see everything (current behavior preserved) - If `IVANTI_BU_FILTER` env var is not set, defaults to current hardcoded value - Compliance backend endpoints unchanged — only frontend selector is scoped - CVE page completely unaffected