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