Update .kiro: remove SQLite hooks, add PostgreSQL migration hook, add workflow steering, sync specs
This commit is contained in:
244
.kiro/specs/multi-bu-tenancy/design.md
Normal file
244
.kiro/specs/multi-bu-tenancy/design.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user