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

@@ -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) => {