feat: add multi-BU tenancy with per-user team scoping (Option B)

- Add bu_teams column to users table (migration + fresh schema)
- Create shared KNOWN_TEAMS constant and validateTeams helper
- Expose user teams in auth middleware, login, and /me responses
- Add bu_teams CRUD to user management routes with audit logging
- Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var
- Add query-time team filtering to GET /findings and /findings/counts
- Update AuthContext with teams helpers and admin scope toggle
- Create AdminScopeToggle component (My Teams / All BUs)
- Scope ReportingPage findings fetch by user teams
- Scope CompliancePage team selector by user teams
- Scope ExportsPage findings exports by user teams
- Add BU teams multi-select to UserManagement create/edit forms
- Display team badges in user list table
This commit is contained in:
Jordan Ramos
2026-05-05 11:04:53 -06:00
parent af951fdc12
commit 2656df94d3
24 changed files with 999 additions and 127 deletions

View File

@@ -9,7 +9,6 @@ import metricDefinitionsRaw from '../../data/metricDefinitions.json';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const TEAMS = ['STEAM', 'ACCESS-ENG'];
// Build definitions lookup map once at module level
const METRIC_DEFINITIONS = {};
@@ -246,9 +245,10 @@ function SeenBadge({ count }) {
// Main Page
// ---------------------------------------------------------------------------
export default function CompliancePage({ onNavigate }) {
const { canWrite, isAdmin } = useAuth();
const { canWrite, isAdmin, getAvailableTeams, adminScope } = useAuth();
const [activeTeam, setActiveTeam] = useState('STEAM');
const availableTeams = getAvailableTeams();
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
const [activeTab, setActiveTab] = useState('active');
const [metricFilter, setMetricFilter] = useState(null);
const [hostSearch, setHostSearch] = useState('');
@@ -298,6 +298,14 @@ export default function CompliancePage({ onNavigate }) {
fetchDevices(activeTeam, activeTab);
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
// When admin scope changes, reset to first available team
useEffect(() => {
const teams = getAvailableTeams();
if (teams.length > 0 && !teams.includes(activeTeam)) {
setActiveTeam(teams[0]);
}
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setMetricFilter(null);
fetchDevices(activeTeam, activeTab);
@@ -419,8 +427,19 @@ export default function CompliancePage({ onNavigate }) {
</div>
{/* ── Team tabs ────────────────────────────────────────────── */}
{availableTeams.length === 0 && !isAdmin() ? (
<div style={{
padding: '1.5rem', marginBottom: '1.5rem',
borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)',
background: 'rgba(245, 158, 11, 0.05)',
fontFamily: 'monospace', fontSize: '0.8rem', color: '#F59E0B',
textAlign: 'center'
}}>
No BU teams assigned to your account. Contact an admin to configure your team access.
</div>
) : (
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
{TEAMS.map(team => {
{availableTeams.map(team => {
const isActive = activeTeam === team;
return (
<button key={team} onClick={() => setActiveTeam(team)}
@@ -441,6 +460,7 @@ export default function CompliancePage({ onNavigate }) {
);
})}
</div>
)}
{/* ── Metric health cards ──────────────────────────────────── */}
{families.length > 0 ? (

View File

@@ -97,8 +97,11 @@ function findingRow(f) {
// ---------------------------------------------------------------------------
// API fetchers
// ---------------------------------------------------------------------------
async function fetchFindings() {
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
async function fetchFindings(teamsParam) {
const url = teamsParam
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
const data = await res.json();
return data.findings || [];
@@ -129,8 +132,8 @@ async function fetchAtlasStatus() {
return res.json();
}
async function fetchAtlasAndFindings() {
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings()]);
async function fetchAtlasAndFindings(teamsParam) {
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
const hostMap = {};
findings.forEach(f => {
@@ -244,7 +247,8 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
// Main page
// ---------------------------------------------------------------------------
export default function ExportsPage() {
const { canExport } = useAuth();
const { canExport, getActiveTeamsParam } = useAuth();
const teamsParam = getActiveTeamsParam();
const [loading, setLoading] = useState(null);
const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState('');
@@ -266,32 +270,35 @@ export default function ExportsPage() {
// ---- Card 1: Ivanti Findings ----
const exportFullFindings = () => run('ivanti-full', async () => {
const findings = await fetchFindings();
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
toXLSX(
[FINDING_HEADERS, ...findings.map(findingRow)],
'All Findings',
`findings-full-${dateStr()}.xlsx`,
`findings-full-${scopeLabel}-${dateStr()}.xlsx`,
);
});
const exportPending = () => run('ivanti-pending', async () => {
const findings = await fetchFindings();
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
});
const exportOverdue = () => run('ivanti-overdue', async () => {
const findings = await fetchFindings();
const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const today = dateStr();
const rows = findings.filter(f => {
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
}).map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`);
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
});
const exportByBU = () => run('ivanti-bu', async () => {
const findings = await fetchFindings();
const findings = await fetchFindings(teamsParam);
const groups = {};
findings.forEach(f => {
const bu = f.buOwnership || 'Unknown';
@@ -308,7 +315,7 @@ export default function ExportsPage() {
// ---- Card 2: FP Workflow Summary ----
const exportFPSummary = () => run('fp-summary', async () => {
const findings = await fetchFindings();
const findings = await fetchFindings(teamsParam);
const fpMap = {};
findings.forEach(f => {
if (!f.workflow?.id) return;
@@ -383,20 +390,20 @@ export default function ExportsPage() {
}
const exportAtlasStatus = () => run('atlas-status', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`);
});
const exportAtlasGaps = () => run('atlas-gaps', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const gaps = atlasRows.filter(a => !a.has_action_plan);
const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`);
});
const exportAtlasFull = () => run('atlas-full', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const withPlans = atlasRows.filter(a => a.has_action_plan);
const withoutPlans = atlasRows.filter(a => !a.has_action_plan);
const sheets = [

View File

@@ -4492,7 +4492,11 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
const seen = new Map();
for (const f of selectedFindings) {
if (f.hostId && !seen.has(f.hostId)) {
seen.set(f.hostId, { hostId: f.hostId, hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId) });
seen.set(f.hostId, {
hostId: f.hostId,
hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId),
findingId: f.id ? Number(f.id) : null,
});
}
}
return [...seen.values()];
@@ -4575,7 +4579,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
if (!commitDate) { setError('Commit date is required'); return; }
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
const needsQualys = NEEDS_QUALYS.has(planType);
if (needsQualys && selectedQualys.size === 0) {
if (needsQualys && selectedQualys.size === 0 && availableQualys.length > 0) {
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
return;
}
@@ -4583,12 +4587,19 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
setSubmitting(true);
setError(null);
try {
const qualysIds = needsQualys ? [...selectedQualys] : [null];
// If qualys IDs are selected, iterate per-qualys; otherwise send one request without qualys_id
const qualysIds = (needsQualys && selectedQualys.size > 0) ? [...selectedQualys] : [null];
const results = [];
for (const qid of qualysIds) {
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
if (qid) body.qualys_id = qid;
// When no qualys_id is available, include the first finding ID per host
// so Atlas can associate the plan with a specific vulnerability
if (!qid && needsQualys) {
const firstWithFinding = hostEntries.find(h => h.findingId);
if (firstWithFinding) body.active_host_findings_id = firstWithFinding.findingId;
}
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
if (archerExc.trim()) body.archer_exc = archerExc.trim();
@@ -4796,7 +4807,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
No vulnerabilities found in Atlas for these hosts
No vulnerabilities found in Atlas for these hosts Qualys ID will be omitted
</div>
)}
@@ -4908,7 +4919,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
// Main ReportingPage
// ---------------------------------------------------------------------------
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const { canWrite } = useAuth();
const { canWrite, getActiveTeamsParam, hasTeams, isAdmin, adminScope } = useAuth();
const [findings, setFindings] = useState([]);
const [total, setTotal] = useState(null);
const [syncedAt, setSyncedAt] = useState(null);
@@ -5041,7 +5052,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const fetchCounts = async () => {
setCountsLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/counts`;
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
} catch (e) {
@@ -5127,7 +5142,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const fetchFindings = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings`;
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (res.ok) {
applyState(data);
@@ -5169,6 +5188,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchCardStatus();
}, []); // eslint-disable-line
// Re-fetch findings and counts when admin scope toggle changes
useEffect(() => {
fetchFindings();
fetchCounts();
}, [adminScope]); // eslint-disable-line
// Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => {