10 KiB
Implementation Plan: Multi-BU Tenancy (Option B)
Overview
Add per-user BU team assignment with query-time filtering on the Reporting and Compliance pages. Uses a single broadened Ivanti sync and filters cached findings based on the logged-in user's assigned teams. The CVE home page remains shared/global.
Tasks
-
1. Database migration and shared teams constant
-
1.1 Create
backend/helpers/teams.jsshared constant- Export
KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'] - Export
validateTeams(teamsString)helper that parses and validates a comma-separated string - Returns
{ valid: boolean, teams: string[], invalid: string[] } - Requirements: 1.3, 1.5
- Export
-
1.2 Create
backend/migrations/add_user_bu_teams.js- Add
bu_teamscolumn (TEXT, NOT NULL, DEFAULT '') to users table - Idempotent — check if column exists before adding
- Follow existing migration pattern (open db, serialize, log progress, close)
- Requirements: 1.1, 1.2
- Add
-
1.3 Update
backend/setup.jsto include bu_teams in fresh schema- Add
bu_teams TEXT NOT NULL DEFAULT ''to the users CREATE TABLE statement - Requirements: 1.1, 1.2
- Add
-
-
2. Backend auth changes — expose teams in session
-
2.1 Update
backend/middleware/auth.jsrequireAuth- Ensure the session JOIN query SELECTs
bu_teamsfrom users table - Parse
bu_teamsinto array and attach asreq.user.teams - Empty string becomes empty array
[] - Requirements: 2.1, 2.4
- Ensure the session JOIN query SELECTs
-
2.2 Update
backend/routes/auth.jslogin response- Include
teamsarray in the login success response object - Requirements: 2.3
- Include
-
2.3 Update
backend/routes/auth.jsGET /me endpoint- Include
teamsarray in the /me response - Requirements: 2.2
- Include
-
-
3. User management — CRUD for bu_teams
-
3.1 Update
backend/routes/users.jsPOST / (create user)- Accept optional
bu_teamsfield in request body - Validate using
validateTeams()helper — return 400 if invalid teams present - Store validated comma-separated string in DB
- Requirements: 1.4, 1.5, 6.1
- Accept optional
-
3.2 Update
backend/routes/users.jsPATCH /:id (update user)- Accept optional
bu_teamsfield in request body - Validate using
validateTeams()helper — return 400 if invalid teams present - Log previous and new bu_teams values in audit trail
- Requirements: 1.4, 1.5, 6.2, 6.3
- Accept optional
-
3.3 Update
backend/routes/users.jsGET endpoints- Include
bu_teams(as raw string) andteams(as parsed array) in user response objects - Requirements: 6.4
- Include
-
-
4. Checkpoint: Verify migration and auth
- Run migration, create a test user with bu_teams, login, verify /me returns teams array.
-
5. Broaden Ivanti sync filter
-
5.1 Make FINDINGS_FILTERS BU value configurable in
backend/routes/ivantiFindings.js- Read
process.env.IVANTI_BU_FILTER— default to'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM' - Replace hardcoded value in FINDINGS_FILTERS array
- Apply same change to CLOSED_COUNT_FILTERS
- Requirements: 3.1, 3.2, 3.3
- Read
-
5.2 Update
backend/.env.examplewith IVANTI_BU_FILTER documentation- Add commented variable with explanation of how broadening works
- Requirements: 3.4
-
-
6. Add query-time team filtering to findings endpoints
-
6.1 Update GET /findings endpoint in
backend/routes/ivantiFindings.js- Accept optional
teamsquery parameter (comma-separated) - Filter findings array by buOwnership matching (case-insensitive includes)
- Return filtered count in response
- If teams param is empty/missing, return all findings (backward-compatible)
- Requirements: 4.1, 4.2, 4.3
- Accept optional
-
6.2 Update GET /findings/counts endpoint
- Accept optional
teamsquery parameter - Filter open count by matching buOwnership against provided teams
- Closed count: if teams provided, note in response that closed count is approximate (full cache doesn't store per-finding closed data)
- Requirements: 8.1, 8.2
- Accept optional
-
6.3 Update GET /findings/counts/history endpoint (if team-scoped history is needed)
- Consider whether historical counts should be team-scoped or remain global
- For MVP: keep global (history is aggregate), document limitation
- Requirements: 8.3
-
-
7. Checkpoint: Verify backend filtering
- Test GET /findings with and without teams param. Verify filtering works correctly.
-
8. Frontend AuthContext — expose teams and admin scope toggle
- 8.1 Update
frontend/src/contexts/AuthContext.js- Store
user.teamsfrom /me response - Add
hasTeams()helper — returns true if teams array is non-empty - Add
isTeamMember(team)helper — returns true if team is in user's teams or user is Admin - Add
adminScopestate backed by localStorage keyadmin_bu_scope(default:'my-teams') - Add
toggleAdminScope()function to flip between'my-teams'and'all' - Add
getActiveTeamsParam()helper — returns comma-joined teams for API calls; returns empty string when Admin is in "All BUs" mode - Add
getAvailableTeams(knownTeams)helper — returns user's teams, or all knownTeams if Admin in "All BUs" mode - Requirements: 2.2, 4.4, 4.6, 4.7, 4.9, 10.1, 10.2, 10.5, 10.6
- Store
- 8.1 Update
-
9. Frontend Admin Scope Toggle component
-
9.1 Create
frontend/src/components/AdminScopeToggle.js- Render a two-state toggle: "My Teams" | "All BUs"
- Only visible when
isAdmin()is true - Calls
toggleAdminScope()from AuthContext on click - Styled to fit in the top nav/header area
- Requirements: 10.1, 10.2, 10.7
-
9.2 Mount AdminScopeToggle in the app header/nav
- Place in NavDrawer or top bar so it's accessible from any page
- Requirements: 10.7
-
-
10. Frontend Reporting Page — scope by teams
-
10.1 Update findings fetch in
ReportingPage.js- Use
getActiveTeamsParam()from AuthContext to build the teams query param - If user has no teams and is not Admin, show "No BU teams assigned" info panel instead of table
- Requirements: 4.4, 4.5
- Use
-
10.2 Update counts fetch in
ReportingPage.js- Append teams param to counts endpoint using same
getActiveTeamsParam() - Requirements: 8.2
- Append teams param to counts endpoint using same
-
10.3 Re-fetch findings when admin scope toggle changes
- Listen to
adminScopevalue from AuthContext; trigger re-fetch on change - Requirements: 4.8
- Listen to
-
-
11. Frontend Compliance Page — scope team selector
-
11.1 Update
CompliancePage.jsTEAMS constant- Replace hardcoded
['STEAM', 'ACCESS-ENG']withgetAvailableTeams(KNOWN_TEAMS)from AuthContext - Non-admin sees only their assigned teams
- Admin in "My Teams" mode sees only their assigned teams
- Admin in "All BUs" mode sees all KNOWN_TEAMS
- Requirements: 5.1, 5.4, 5.5
- Replace hardcoded
-
11.2 Handle single-team and no-team states
- If user has one team: default to it, optionally hide selector
- If user has no teams and is not Admin: show "No teams assigned" message
- Requirements: 5.2, 5.3
-
11.3 Update
ComplianceChartsPanel.jsteam colors- Ensure chart only renders data for user's available teams
- Requirements: 5.1
-
11.4 Re-render team selector when admin scope toggle changes
- Listen to
adminScopefrom AuthContext; update available teams list on change - Requirements: 5.5, 5.6
- Listen to
-
-
12. Frontend Exports Page — scope by teams
-
12.1 Update
ExportsPage.jsfindings fetch- Use
getActiveTeamsParam()to pass teams filter - Requirements: 9.1
- Use
-
12.2 Add BU scope indicator to export filenames
- Append team names to exported file names (e.g.
findings-STEAM-2026-05-05.xlsx) - When in "All BUs" mode, use
findings-ALL-2026-05-05.xlsx - Requirements: 9.3
- Append team names to exported file names (e.g.
-
-
13. Frontend User Management — BU team assignment UI
-
13.1 Add team multi-select to create user form in
UserManagement.js- Checkbox group with all KNOWN_TEAMS options
- Sends comma-separated string as
bu_teamsfield - Requirements: 6.1
-
13.2 Add team multi-select to edit user form
- Pre-populate with user's current teams
- Requirements: 6.2
-
13.3 Display teams in user list table
- Show team badges for each user
- Show warning indicator for users with no teams assigned
- Requirements: 6.4, 6.5
-
-
14. Verify CVE page is unaffected
- 14.1 Confirm no team filtering on CVE routes or frontend
- Verify CVE page does not reference user.teams for data fetching
- Requirements: 7.1, 7.2, 7.3
- 14.1 Confirm no team filtering on CVE routes or frontend
-
15. Final checkpoint: End-to-end verification
- Create two test users with different bu_teams assignments. Log in as each and verify:
- Reporting page shows only their BU's findings
- Compliance page shows only their teams in selector
- CVE page shows same data for both
- Exports are scoped correctly
- Admin scope toggle switches between "My Teams" and "All BUs" correctly
- Admin in "My Teams" mode sees same scoped view as a regular user with same teams
- Toggle state persists across page refresh
- Create two test users with different bu_teams assignments. Log in as each and verify:
Notes
- The
bu_teamsfield is a UX scoping mechanism, not a hard security boundary. Admin and direct API callers can always access all data. - The buOwnership matching uses case-insensitive
includes()to handle Ivanti's full BU names (e.g. "NTS-AEO-STEAM" matches user team "STEAM"). - Historical counts (IvantiCountsChart) remain global for MVP — per-BU historical tracking would require schema changes to the counts history table.
- The compliance backend endpoints are unchanged — only the frontend team selector is scoped.
- After running the migration, an admin must assign bu_teams to existing users. Until then, non-Admin users with empty teams will see the "no teams assigned" message on Reporting/Compliance.
- The Admin scope toggle defaults to "My Teams" so that your daily workflow (STEAM + ACCESS-ENG) is the default view. Switch to "All BUs" when you need the full picture across all business units.
- The scope toggle state is shared across Reporting, Compliance, and Exports — one toggle controls all three pages consistently.
- Admin users with empty bu_teams: "My Teams" mode behaves identically to "All BUs" (no filtering in either case).