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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user