Files
cve-dashboard/.kiro/specs/multi-bu-tenancy/design.md

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