245 lines
11 KiB
Markdown
245 lines
11 KiB
Markdown
# 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
|