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

@@ -1,6 +1,7 @@
// User Management Routes (Admin only)
const express = require('express');
const bcrypt = require('bcryptjs');
const { validateTeams } = require('../helpers/teams');
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
const router = express.Router();
@@ -13,7 +14,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
try {
const users = await new Promise((resolve, reject) => {
db.all(
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login
FROM users ORDER BY created_at DESC`,
(err, rows) => {
if (err) reject(err);
@@ -21,7 +22,12 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
}
);
});
res.json(users);
// Parse bu_teams into teams array for each user
const usersWithTeams = users.map(u => ({
...u,
teams: u.bu_teams ? u.bu_teams.split(',').filter(Boolean) : []
}));
res.json(usersWithTeams);
} catch (err) {
console.error('Get users error:', err);
res.status(500).json({ error: 'Failed to fetch users' });
@@ -33,7 +39,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
try {
const user = await new Promise((resolve, reject) => {
db.get(
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login
FROM users WHERE id = ?`,
[req.params.id],
(err, row) => {
@@ -47,7 +53,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
res.json({
...user,
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
});
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to fetch user' });
@@ -56,7 +65,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Create new user
router.post('/', async (req, res) => {
const { username, email, password, group } = req.body;
const { username, email, password, group, bu_teams } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
if (!username || !email || !password) {
@@ -69,14 +78,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
}
// Validate bu_teams if provided
const teamsStr = bu_teams || '';
if (teamsStr) {
const teamsResult = validateTeams(teamsStr);
if (!teamsResult.valid) {
return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` });
}
}
try {
const passwordHash = await bcrypt.hash(password, 10);
const result = await new Promise((resolve, reject) => {
db.run(
`INSERT INTO users (username, email, password_hash, user_group)
VALUES (?, ?, ?, ?)`,
[username, email, passwordHash, userGroup],
`INSERT INTO users (username, email, password_hash, user_group, bu_teams)
VALUES (?, ?, ?, ?, ?)`,
[username, email, passwordHash, userGroup, teamsStr],
function(err) {
if (err) reject(err);
else resolve({ id: this.lastID });
@@ -90,7 +108,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
action: 'user_create',
entityType: 'user',
entityId: String(result.id),
details: { created_username: username, group: userGroup },
details: { created_username: username, group: userGroup, bu_teams: teamsStr },
ipAddress: req.ip
});
@@ -100,7 +118,9 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
id: result.id,
username,
email,
group: userGroup
group: userGroup,
bu_teams: teamsStr,
teams: teamsStr ? teamsStr.split(',').filter(Boolean) : []
}
});
} catch (err) {
@@ -114,7 +134,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Update user
router.patch('/:id', async (req, res) => {
const { username, email, password, group, is_active } = req.body;
const { username, email, password, group, is_active, bu_teams } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const userId = req.params.id;
@@ -133,11 +153,21 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
return res.status(400).json({ error: 'Cannot deactivate your own account' });
}
// Validate bu_teams if provided
if (typeof bu_teams === 'string') {
if (bu_teams !== '') {
const teamsResult = validateTeams(bu_teams);
if (!teamsResult.valid) {
return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` });
}
}
}
try {
// Fetch current user record before update (needed for group change audit)
const currentUser = await new Promise((resolve, reject) => {
db.get(
'SELECT user_group FROM users WHERE id = ?',
'SELECT user_group, bu_teams FROM users WHERE id = ?',
[userId],
(err, row) => {
if (err) reject(err);
@@ -174,6 +204,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
updates.push('is_active = ?');
values.push(is_active ? 1 : 0);
}
if (typeof bu_teams === 'string') {
updates.push('bu_teams = ?');
values.push(bu_teams);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
@@ -198,6 +232,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
if (group) updatedFields.group = group;
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
if (password) updatedFields.password_changed = true;
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
logAudit(db, {
userId: req.user.id,
@@ -225,6 +260,22 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
});
}
// Log specific audit entry for bu_teams changes
if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) {
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_teams_change',
entityType: 'user',
entityId: String(userId),
details: {
previous_teams: currentUser.bu_teams || '',
new_teams: bu_teams
},
ipAddress: req.ip
});
}
// If user was deactivated, delete their sessions
if (is_active === false) {
await new Promise((resolve) => {