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

11 KiB

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

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

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:

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:

// 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:

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:

// 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:

const [user, setUser] = useState(null);
// user.teams = ['STEAM', 'ACCESS-ENG'] or []

Add helper:

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:

# 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