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

@@ -132,69 +132,6 @@ function createJiraTicketsRouter(db) {
}
});
/**
* POST /api/jira/search
*
* Search Jira issues using a JQL query. Results are capped at 1000 per page.
* Charter compliance: JQL must include project+updated, assignee+updated,
* or status+updated. Fields are always specified explicitly.
*
* @body {string} jql - JQL query string (required, max 2000 chars)
* @body {number} [startAt] - Pagination offset
* @body {number} [maxResults] - Page size (max 1000)
* @body {string[]} [fields] - Explicit field list for the Jira response
* @returns {object} 200 - { total, startAt, maxResults, issues: [{ key, summary, status, assignee, priority, issuetype, created, updated }] }
* @returns {object} 400 - { error } when JQL is missing or too long
* @returns {object} 429 - { error } when Jira rate limit exceeded
* @returns {object} 502 - { error, details } on Jira search failure
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.post('/search', requireAuth(db), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' });
}
const { jql, startAt, maxResults, fields } = req.body;
if (!jql || typeof jql !== 'string' || jql.trim().length === 0) {
return res.status(400).json({ error: 'JQL query is required.' });
}
if (jql.length > 2000) {
return res.status(400).json({ error: 'JQL query too long (max 2000 chars).' });
}
try {
const result = await jiraApi.searchIssues(jql, {
startAt,
maxResults: Math.min(maxResults || 1000, 1000),
fields: fields || undefined
});
if (result.ok) {
const data = result.data;
return res.json({
total: data.total,
startAt: data.startAt,
maxResults: data.maxResults,
issues: (data.issues || []).map(issue => ({
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status ? issue.fields.status.name : null,
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
priority: issue.fields.priority ? issue.fields.priority.name : null,
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
created: issue.fields.created,
updated: issue.fields.updated
}))
});
}
if (result.rateLimited) {
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
}
return res.status(502).json({ error: 'Jira search failed.', details: result.body });
} catch (err) {
return res.status(502).json({ error: err.message });
}
});
/**
* POST /api/jira/create-in-jira
*