feat: implement group-based access control (Admin, Standard_User, Leadership, Read_Only)
- Add user_group migration and created_by column migration - Replace requireRole middleware with requireGroup - Update all backend routes to use group-based authorization - Add Standard_User conditional delete with ownership, state, and compliance checks - Add cascade impact check for CVE deletes - Update AuthContext with group-based permission helpers - Update all frontend components for group-based rendering - Update UserManagement UI with group dropdown, confirmation dialogs, self-demotion prevention
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// routes/archerTickets.js
|
||||
const express = require('express');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
// Validation helpers
|
||||
@@ -48,7 +48,7 @@ function createArcherTicketsRouter(db) {
|
||||
});
|
||||
|
||||
// Create Archer ticket
|
||||
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||
|
||||
// Validation
|
||||
@@ -74,9 +74,9 @@ function createArcherTicketsRouter(db) {
|
||||
const validatedStatus = status || 'Draft';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating Archer ticket:', err);
|
||||
@@ -104,7 +104,7 @@ function createArcherTicketsRouter(db) {
|
||||
});
|
||||
|
||||
// Update Archer ticket
|
||||
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { exc_number, archer_url, status } = req.body;
|
||||
|
||||
@@ -184,8 +184,29 @@ function createArcherTicketsRouter(db) {
|
||||
});
|
||||
});
|
||||
|
||||
// Helper: perform the actual Archer ticket deletion
|
||||
function performArcherDelete(db, req, res, id, ticket) {
|
||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: id,
|
||||
details: { deleted: ticket },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
});
|
||||
}
|
||||
|
||||
// Delete Archer ticket
|
||||
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
@@ -197,23 +218,45 @@ function createArcherTicketsRouter(db) {
|
||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||
}
|
||||
|
||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
// Admin bypasses all delete restrictions
|
||||
if (req.user.group === 'Admin') {
|
||||
return performArcherDelete(db, req, res, id, ticket);
|
||||
}
|
||||
|
||||
// Standard_User: ownership check
|
||||
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const excNumber = ticket.exc_number;
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||
[`%${excNumber}%`],
|
||||
(compErr, compLinks) => {
|
||||
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(excNumber);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
|
||||
return performArcherDelete(db, req, res, id, ticket);
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: id,
|
||||
details: { deleted: ticket },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
});
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Audit Log Routes (Admin only)
|
||||
const express = require('express');
|
||||
|
||||
function createAuditLogRouter(db, requireAuth, requireRole) {
|
||||
function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(requireAuth(db), requireRole('admin'));
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
|
||||
// Get paginated audit logs with filters
|
||||
router.get('/', async (req, res) => {
|
||||
|
||||
@@ -110,7 +110,7 @@ function createAuthRouter(db, logAudit) {
|
||||
action: 'login',
|
||||
entityType: 'auth',
|
||||
entityId: null,
|
||||
details: { role: user.role },
|
||||
details: { group: user.user_group },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ function createAuthRouter(db, logAudit) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
group: user.user_group
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -183,7 +183,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.role, u.is_active
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, 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')`,
|
||||
@@ -210,7 +210,7 @@ function createAuthRouter(db, logAudit) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
role: session.role
|
||||
group: session.user_group
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -213,7 +213,7 @@ function groupByHostname(rows, noteHostnames) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
const router = express.Router();
|
||||
|
||||
// Idempotent column additions — errors mean column already exists, which is fine
|
||||
@@ -228,7 +228,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
|
||||
// Returns diff counts + tempFile path for the commit step.
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/preview', requireRole('editor', 'admin'), (req, res) => {
|
||||
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
upload.single('file')(req, res, async (uploadErr) => {
|
||||
if (uploadErr) {
|
||||
return res.status(400).json({ error: uploadErr.message });
|
||||
@@ -291,7 +291,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
|
||||
// Commit a previewed upload to the DB.
|
||||
// Body: { tempFile, filename, report_date }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/commit', requireRole('editor', 'admin'), async (req, res) => {
|
||||
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { tempFile, filename, report_date } = req.body;
|
||||
|
||||
if (!tempFile || typeof tempFile !== 'string') {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -899,7 +899,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
|
||||
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const { field, value } = req.body;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
function createKnowledgeBaseRouter(db, upload) {
|
||||
@@ -40,7 +40,7 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
}
|
||||
|
||||
// POST /api/knowledge-base/upload - Upload new document
|
||||
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
|
||||
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('[KB Upload] Multer error:', err);
|
||||
@@ -302,7 +302,7 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
});
|
||||
|
||||
// DELETE /api/knowledge-base/:id - Delete article
|
||||
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(requireAuth(db), requireRole('admin'));
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
|
||||
// Get all users
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const users = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT id, username, email, role, is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT id, username, email, role, is_active, created_at, last_login
|
||||
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||
FROM users WHERE id = ?`,
|
||||
[req.params.id],
|
||||
(err, row) => {
|
||||
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
// Create new user
|
||||
router.post('/', async (req, res) => {
|
||||
const { username, email, password, role } = req.body;
|
||||
const { username, email, password, group } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'Username, email, and password are required' });
|
||||
}
|
||||
|
||||
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
|
||||
const userGroup = group || 'Read_Only';
|
||||
|
||||
if (!VALID_GROUPS.includes(userGroup)) {
|
||||
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, role)
|
||||
`INSERT INTO users (username, email, password_hash, user_group)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[username, email, passwordHash, role || 'viewer'],
|
||||
[username, email, passwordHash, userGroup],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID });
|
||||
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
action: 'user_create',
|
||||
entityType: 'user',
|
||||
entityId: String(result.id),
|
||||
details: { created_username: username, role: role || 'viewer' },
|
||||
details: { created_username: username, group: userGroup },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
id: result.id,
|
||||
username,
|
||||
email,
|
||||
role: role || 'viewer'
|
||||
group: userGroup
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -111,12 +114,18 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
// Update user
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { username, email, password, role, is_active } = req.body;
|
||||
const { username, email, password, group, is_active } = req.body;
|
||||
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||
const userId = req.params.id;
|
||||
|
||||
// Prevent self-demotion from admin
|
||||
if (userId == req.user.id && role && role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove your own admin role' });
|
||||
// Validate group if provided
|
||||
if (group && !VALID_GROUPS.includes(group)) {
|
||||
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||
}
|
||||
|
||||
// Prevent admin self-demotion
|
||||
if (userId == req.user.id && group && group !== 'Admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove your own admin group' });
|
||||
}
|
||||
|
||||
// Prevent self-deactivation
|
||||
@@ -125,6 +134,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
}
|
||||
|
||||
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 = ?',
|
||||
[userId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
updates.push('password_hash = ?');
|
||||
values.push(passwordHash);
|
||||
}
|
||||
if (role) {
|
||||
if (!['admin', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
updates.push('role = ?');
|
||||
values.push(role);
|
||||
if (group) {
|
||||
updates.push('user_group = ?');
|
||||
values.push(group);
|
||||
}
|
||||
if (typeof is_active === 'boolean') {
|
||||
updates.push('is_active = ?');
|
||||
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
const updatedFields = {};
|
||||
if (username) updatedFields.username = username;
|
||||
if (email) updatedFields.email = email;
|
||||
if (role) updatedFields.role = role;
|
||||
if (group) updatedFields.group = group;
|
||||
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||
if (password) updatedFields.password_changed = true;
|
||||
|
||||
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
// Log specific audit entry for group changes
|
||||
if (group && group !== currentUser.user_group) {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_group_change',
|
||||
entityType: 'user',
|
||||
entityId: String(userId),
|
||||
details: {
|
||||
previous_group: currentUser.user_group,
|
||||
new_group: group
|
||||
},
|
||||
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