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:
@@ -7,9 +7,19 @@ const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// Diagnostic log helper — writes to atlas-sync-debug.log in the backend folder
|
||||
function syncLog(msg) {
|
||||
const line = `${new Date().toISOString()} ${msg}\n`;
|
||||
try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,7 +139,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache`
|
||||
`SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
@@ -227,7 +237,34 @@ function createAtlasRouter(db, requireAuth) {
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0 ? 1 : 0;
|
||||
|
||||
console.log(`[Atlas Sync] Host ${hostId}: status=${result.status}, activePlans=${activePlans.length}, allPlans=${allPlans.length}, hasActionPlan=${hasActionPlan}`);
|
||||
|
||||
try {
|
||||
// If Atlas returns 0 plans but we have a recent optimistic
|
||||
// entry (from bulk creation within the last 10 minutes),
|
||||
// keep the optimistic value — Atlas's GET may lag behind.
|
||||
if (hasActionPlan === 0) {
|
||||
const existing = await dbGet(db,
|
||||
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = ?`,
|
||||
[hostId]
|
||||
);
|
||||
if (existing && existing.has_action_plan === 1) {
|
||||
let existingPlans = [];
|
||||
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
|
||||
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
|
||||
if (hasBulkStub) {
|
||||
const ageMs = Date.now() - new Date(existing.synced_at + 'Z').getTime();
|
||||
const TEN_MINUTES = 10 * 60 * 1000;
|
||||
if (ageMs < TEN_MINUTES) {
|
||||
console.log(`[Atlas Sync] Host ${hostId}: keeping optimistic bulk-create entry (${Math.round(ageMs / 1000)}s old)`);
|
||||
synced++;
|
||||
withPlans++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
@@ -246,7 +283,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
if (hasActionPlan) withPlans++;
|
||||
} else {
|
||||
failed++;
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}, body=${result.body}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,6 +543,52 @@ function createAtlasRouter(db, requireAuth) {
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
|
||||
// Optimistically update local cache for all submitted hosts.
|
||||
// Atlas's individual GET endpoint may lag behind the bulk
|
||||
// creation, so we mark every host as having a plan now rather
|
||||
// than waiting for the next sync to discover it.
|
||||
for (const hid of host_ids) {
|
||||
try {
|
||||
const existing = await dbGet(db,
|
||||
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = ?`,
|
||||
[hid]
|
||||
);
|
||||
|
||||
let existingPlans = [];
|
||||
if (existing && existing.plans_json) {
|
||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
||||
const updatedPlans = [...existingPlans, stubPlan];
|
||||
const newCount = updatedPlans.length;
|
||||
|
||||
await dbRun(db,
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, 1, ?, ?, datetime('now'))
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = 1,
|
||||
plan_count = excluded.plan_count,
|
||||
plans_json = excluded.plans_json,
|
||||
synced_at = excluded.synced_at`,
|
||||
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||
);
|
||||
} catch (cacheErr) {
|
||||
console.error('[Atlas] Cache update failed for host', hid, ':', cacheErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_BULK_CREATE_PLANS',
|
||||
entityType: 'atlas_action_plan',
|
||||
entityId: null,
|
||||
details: { host_ids, plan_type, commit_date, count: host_ids.length },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
|
||||
@@ -143,7 +143,8 @@ function createAuthRouter(db, logAudit) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
group: user.user_group
|
||||
group: user.user_group,
|
||||
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -222,7 +223,7 @@ function createAuthRouter(db, logAudit) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -249,7 +250,8 @@ function createAuthRouter(db, logAudit) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
group: session.user_group
|
||||
group: session.user_group,
|
||||
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
121
backend/routes/feedback.js
Normal file
121
backend/routes/feedback.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
|
||||
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function createFeedbackRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
const GITLAB_URL = process.env.GITLAB_URL || '';
|
||||
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
||||
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
||||
|
||||
/**
|
||||
* POST /api/feedback
|
||||
*
|
||||
* Create a GitLab issue from a bug report or feature request.
|
||||
* Available to all authenticated users.
|
||||
*
|
||||
* @body {string} type - "bug" or "feature"
|
||||
* @body {string} title - Issue title
|
||||
* @body {string} description - Issue description
|
||||
* @body {string} [page] - Which dashboard page the user was on
|
||||
*/
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
||||
return res.status(503).json({ error: 'Feedback integration not configured' });
|
||||
}
|
||||
|
||||
const { type, title, description, page } = req.body;
|
||||
|
||||
if (!type || !title || !description) {
|
||||
return res.status(400).json({ error: 'type, title, and description are required' });
|
||||
}
|
||||
|
||||
if (!['bug', 'feature'].includes(type)) {
|
||||
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
|
||||
}
|
||||
|
||||
const labels = type === 'bug' ? 'bug' : 'enhancement';
|
||||
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
||||
const username = req.user?.username || 'unknown';
|
||||
|
||||
const body = [
|
||||
`**Submitted by:** ${username}`,
|
||||
page ? `**Page:** ${page}` : null,
|
||||
`**Type:** ${prefix}`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
description,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const postData = JSON.stringify({
|
||||
title: `[${prefix}] ${title}`,
|
||||
description: body,
|
||||
labels,
|
||||
});
|
||||
|
||||
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const parsed = new URL(apiUrl);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const reqOpts = {
|
||||
method: 'POST',
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'PRIVATE-TOKEN': GITLAB_PAT,
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
rejectAuthorized: false,
|
||||
};
|
||||
|
||||
const apiReq = transport.request(reqOpts, (apiRes) => {
|
||||
let data = '';
|
||||
apiRes.on('data', chunk => data += chunk);
|
||||
apiRes.on('end', () => {
|
||||
try {
|
||||
resolve({ status: apiRes.statusCode, body: JSON.parse(data) });
|
||||
} catch {
|
||||
resolve({ status: apiRes.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
apiReq.on('error', reject);
|
||||
apiReq.write(postData);
|
||||
apiReq.end();
|
||||
});
|
||||
|
||||
if (result.status === 201) {
|
||||
console.log(`[Feedback] Issue #${result.body.iid} created by ${username}: ${title}`);
|
||||
res.json({
|
||||
success: true,
|
||||
issue: {
|
||||
id: result.body.iid,
|
||||
url: result.body.web_url,
|
||||
title: result.body.title,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(`[Feedback] GitLab API returned ${result.status}:`, result.body);
|
||||
res.status(502).json({ error: 'GitLab API error', details: result.body });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Feedback] Request failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to connect to GitLab' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createFeedbackRouter;
|
||||
@@ -8,6 +8,10 @@ const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Configurable BU filter — broadened via env var to support multi-tenancy.
|
||||
// Users see only their assigned teams' findings (filtered at query time).
|
||||
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
||||
|
||||
const FINDINGS_FILTERS = [
|
||||
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||
{
|
||||
@@ -16,7 +20,7 @@ const FINDINGS_FILTERS = [
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
value: BU_FILTER_VALUE,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
@@ -47,7 +51,7 @@ const CLOSED_COUNT_FILTERS = [
|
||||
operator: 'IN',
|
||||
orWithPrevious: false,
|
||||
implicitFilters: [],
|
||||
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||
value: BU_FILTER_VALUE,
|
||||
caseSensitive: false
|
||||
},
|
||||
{
|
||||
@@ -1118,13 +1122,30 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
* GET /api/ivanti/findings
|
||||
*
|
||||
* Return cached Ivanti findings with notes and overrides merged in.
|
||||
* Accepts optional `teams` query parameter (comma-separated) to filter
|
||||
* findings by buOwnership. If omitted, returns all findings.
|
||||
*
|
||||
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object }
|
||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {Object} 200 - { findings: Array<Object>, lastSync: string|null, overrides: Object, total: number }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readStateWithNotes(db));
|
||||
const state = await readStateWithNotes(db);
|
||||
|
||||
// Filter by teams if provided
|
||||
const teamsParam = req.query.teams;
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
state.findings = state.findings.filter(f =>
|
||||
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
|
||||
);
|
||||
state.total = state.findings.length;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(state);
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading findings' });
|
||||
}
|
||||
@@ -1152,13 +1173,32 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
* GET /api/ivanti/findings/counts
|
||||
*
|
||||
* Return open vs closed finding totals for the pie chart.
|
||||
* Accepts optional `teams` query parameter to scope the open count
|
||||
* to specific BUs. Closed count remains global (approximate) when filtered.
|
||||
*
|
||||
* @returns {Object} 200 - { open: number, closed: number }
|
||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
||||
* @returns {Object} 200 - { open: number, closed: number, filtered: boolean }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/counts', async (req, res) => {
|
||||
try {
|
||||
res.json(await readCounts(db));
|
||||
const teamsParam = req.query.teams;
|
||||
|
||||
if (teamsParam) {
|
||||
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
||||
if (teams.length > 0) {
|
||||
// For open count, filter the cached findings by team
|
||||
const state = await readState(db);
|
||||
const filtered = state.findings.filter(f =>
|
||||
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
|
||||
);
|
||||
// Closed count is global — we don't store per-finding closed data
|
||||
const counts = await readCounts(db);
|
||||
return res.json({ open: filtered.length, closed: counts.closed, filtered: true });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...(await readCounts(db)), filtered: false });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading counts' });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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