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:
jramos
2026-04-06 16:18:07 -06:00
parent 1ef57b0504
commit 73fd747576
19 changed files with 1171 additions and 149 deletions

View File

@@ -12,7 +12,7 @@ function requireAuth(db) {
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.role, 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')`,
@@ -37,7 +37,8 @@ function requireAuth(db) {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
role: session.role,
group: session.user_group
};
next();
@@ -48,18 +49,18 @@ function requireAuth(db) {
};
}
// Require specific role(s)
function requireRole(...allowedRoles) {
// Require specific group(s)
function requireGroup(...allowedGroups) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
if (!allowedGroups.includes(req.user.group)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: allowedRoles,
current: req.user.role
required: allowedGroups,
current: req.user.group
});
}
@@ -67,4 +68,4 @@ function requireRole(...allowedRoles) {
};
}
module.exports = { requireAuth, requireRole };
module.exports = { requireAuth, requireGroup };

View File

@@ -0,0 +1,76 @@
// Migration: Add created_by column to cves, archer_tickets, and jira_tickets tables
// Stores the user ID of the creator for ownership-based delete checks.
// Idempotent — safe to run multiple times.
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
const tables = ['cves', 'archer_tickets', 'jira_tickets'];
let completed = 0;
db.serialize(() => {
tables.forEach((table) => {
db.all(`PRAGMA table_info(${table})`, (err, columns) => {
if (err) {
// Table may not exist yet — skip gracefully
console.log(`⚠ Could not inspect ${table}: ${err.message} — skipping`);
completed++;
if (completed === tables.length) resolve();
return;
}
const hasCreatedBy = columns.some(col => col.name === 'created_by');
if (hasCreatedBy) {
console.log(`${table}.created_by already exists — skipping`);
completed++;
if (completed === tables.length) resolve();
return;
}
db.run(
`ALTER TABLE ${table} ADD COLUMN created_by INTEGER REFERENCES users(id)`,
(err) => {
if (err) {
reject(err);
return;
}
console.log(`✓ Added created_by column to ${table}`);
completed++;
if (completed === tables.length) resolve();
}
);
});
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_created_by_columns migration...');
runMigration(db)
.then(() => {
console.log('Migration complete!');
db.close(() => {
console.log('Database connection closed.');
});
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -0,0 +1,119 @@
// Migration: Add user_group column to users table and map legacy roles
// Mapping: admin→Admin, editor→Standard_User, viewer→Read_Only
// NULL/unrecognized roles default to Read_Only
// Idempotent — safe to run multiple times
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Check if user_group column already exists
db.all("PRAGMA table_info(users)", (err, columns) => {
if (err) {
reject(err);
return;
}
const hasUserGroup = columns.some(col => col.name === 'user_group');
if (hasUserGroup) {
console.log('✓ user_group column already exists — skipping migration');
resolve();
return;
}
console.log('Adding user_group column to users table...');
// SQLite doesn't support ADD COLUMN with CHECK inline in all versions,
// so we add the column first, map values, then recreate with constraint.
// However, SQLite also doesn't support ALTER TABLE ADD CONSTRAINT.
// Strategy: add column, map values, create index.
// The CHECK constraint is enforced via table rebuild.
db.run(
`ALTER TABLE users ADD COLUMN user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only'`,
(err) => {
if (err) {
reject(err);
return;
}
console.log('✓ Added user_group column');
// Map existing roles to groups
db.run(
`UPDATE users SET user_group = 'Admin' WHERE role = 'admin'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} admin(s) → Admin`);
db.run(
`UPDATE users SET user_group = 'Standard_User' WHERE role = 'editor'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} editor(s) → Standard_User`);
db.run(
`UPDATE users SET user_group = 'Read_Only' WHERE role = 'viewer'`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} viewer(s) → Read_Only`);
// Map NULL or unrecognized roles to Read_Only
db.run(
`UPDATE users SET user_group = 'Read_Only' WHERE user_group = 'Read_Only' AND role NOT IN ('admin', 'editor', 'viewer')`,
function(err) {
if (err) { reject(err); return; }
console.log(` ✓ Mapped ${this.changes} unrecognized role(s) → Read_Only`);
// Create index on user_group
db.run(
`CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group)`,
(err) => {
if (err) { reject(err); return; }
console.log('✓ Created idx_users_user_group index');
console.log('Migration complete!');
resolve();
}
);
}
);
}
);
}
);
}
);
}
);
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Starting add_user_groups migration...');
runMigration(db)
.then(() => {
db.close(() => {
console.log('Database connection closed.');
});
})
.catch((err) => {
console.error('Migration failed:', err);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -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' });
});
);
});
});

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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') {

View File

@@ -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;

View File

@@ -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 = ?';

View File

@@ -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) => {

View File

@@ -12,7 +12,7 @@ const path = require('path');
const fs = require('fs');
// Auth imports
const { requireAuth, requireRole } = require('./middleware/auth');
const { requireAuth, requireGroup } = require('./middleware/auth');
const createAuthRouter = require('./routes/auth');
const createUsersRouter = require('./routes/users');
const createAuditLogRouter = require('./routes/auditLog');
@@ -161,10 +161,10 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
app.use('/api/auth', createAuthRouter(db, logAudit));
// User management routes (admin only)
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit));
app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
// Audit log routes (admin only)
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
// NVD lookup routes (authenticated users)
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
@@ -224,7 +224,7 @@ app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole));
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
// ========== CVE ENDPOINTS ==========
@@ -353,7 +353,7 @@ app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
});
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, severity, description, published_date } = req.body;
// Input validation
@@ -374,11 +374,11 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
}
const query = `
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
VALUES (?, ?, ?, ?, ?)
INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`;
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
if (err) {
console.error('DATABASE ERROR:', err);
if (err.message.includes('UNIQUE constraint failed')) {
@@ -407,7 +407,7 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
// Update CVE status (editor or admin)
app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cveId } = req.params;
const { status } = req.body;
@@ -435,7 +435,7 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
});
// Bulk sync CVE data from NVD (editor or admin)
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { updates } = req.body;
if (!Array.isArray(updates) || updates.length === 0) {
return res.status(400).json({ error: 'No updates provided' });
@@ -505,7 +505,7 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
// ========== CVE EDIT & DELETE ENDPOINTS ==========
// Edit single CVE entry (editor or admin)
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { cve_id, vendor, severity, description, published_date, status } = req.body;
@@ -649,7 +649,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
});
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cveId } = req.params;
// Get all rows for this CVE ID to know what we're deleting
@@ -657,6 +657,151 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
// Ownership check: Standard_User can only delete CVEs they created
if (req.user.group === 'Standard_User') {
const notOwned = rows.some(row => row.created_by !== req.user.id);
if (notOwned) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Cascade impact check for Standard_User
// Query all three cascade-deleted resource types in parallel
db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => {
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => {
// If jira_tickets table doesn't exist yet, treat as empty
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) {
jiraTickets = [];
} else if (jiraErr) {
console.error(jiraErr);
return res.status(500).json({ error: 'Internal server error.' });
}
db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => {
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
const allTickets = [
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
];
// If no tickets at all, no compliance linkage possible — return cascade info
if (allTickets.length === 0) {
return res.json({
cascade_impact: {
archer_tickets: [],
jira_tickets: [],
documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })),
blocked: false,
blocked_reason: null
}
});
}
// Check compliance linkage for each ticket
// A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id
// appears in active compliance_items extra_json
const likeConditions = [];
const likeParams = [];
for (const t of allTickets) {
likeConditions.push('ci.extra_json LIKE ?');
likeParams.push(`%${t.key}%`);
}
// Also check if the CVE ID itself appears in compliance extra_json
likeConditions.push('ci.extra_json LIKE ?');
likeParams.push(`%${cveId}%`);
db.all(
`SELECT ci.id, ci.extra_json, cu.report_date
FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
likeParams,
(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.' });
}
// Determine which tickets are compliance-linked by checking extra_json matches
const linkedTicketKeys = new Set();
for (const cl of (compLinks || [])) {
const json = cl.extra_json || '';
for (const t of allTickets) {
if (json.includes(t.key)) {
linkedTicketKeys.add(`${t.source}:${t.id}`);
}
}
// If CVE ID itself is in compliance data, all tickets are considered linked
if (json.includes(cveId)) {
for (const t of allTickets) {
linkedTicketKeys.add(`${t.source}:${t.id}`);
}
}
}
const archerTicketsResult = (archerTickets || []).map(t => ({
id: t.id,
exc_number: t.exc_number,
compliance_linked: linkedTicketKeys.has(`archer:${t.id}`)
}));
const jiraTicketsResult = (jiraTickets || []).map(t => ({
id: t.id,
ticket_key: t.ticket_key,
compliance_linked: linkedTicketKeys.has(`jira:${t.id}`)
}));
const documentsResult = (docs || []).map(d => ({
id: d.id,
name: d.name,
type: d.type
}));
const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked)
|| jiraTicketsResult.some(t => t.compliance_linked);
if (hasComplianceLink) {
const blockedArcher = archerTicketsResult.find(t => t.compliance_linked);
const blockedJira = jiraTicketsResult.find(t => t.compliance_linked);
const blockedLabel = blockedArcher
? `Archer ticket ${blockedArcher.exc_number}`
: `JIRA ticket ${blockedJira.ticket_key}`;
return res.status(403).json({
error: 'CVE deletion blocked: associated ticket linked to compliance report',
cascade_impact: {
archer_tickets: archerTicketsResult,
jira_tickets: jiraTicketsResult,
documents: documentsResult,
blocked: true,
blocked_reason: `${blockedLabel} is linked to a compliance report`
}
});
}
// Not blocked — return cascade impact for frontend warning
return res.json({
cascade_impact: {
archer_tickets: archerTicketsResult,
jira_tickets: jiraTicketsResult,
documents: documentsResult,
blocked: false,
blocked_reason: null
}
});
}
);
});
});
});
return; // Exit early — Standard_User flow handled above
}
// Admin flow: proceed directly with deletion (no cascade check)
// Delete all documents from DB
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
if (docErr) console.error('Error deleting documents:', docErr);
@@ -689,13 +834,18 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
});
// Delete single CVE vendor entry (editor or admin)
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
// Ownership check: Standard_User can only delete CVEs they created
if (req.user.group === 'Standard_User' && cve.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Delete associated documents from DB
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
if (docErr) console.error('Error fetching documents:', docErr);
@@ -771,7 +921,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
});
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
console.error('Upload error:', err.message);
@@ -879,7 +1029,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
});
});
// Delete document (admin only)
app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => {
app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
const { id } = req.params;
// First get the file path to delete the actual file
@@ -981,7 +1131,7 @@ app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
});
// Create JIRA ticket
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
// Validation
@@ -1007,11 +1157,11 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
const ticketStatus = status || 'Open';
const query = `
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) {
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
if (err) {
console.error('Error creating JIRA ticket:', err);
return res.status(500).json({ error: 'Internal server error.' });
@@ -1035,7 +1185,7 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
});
// Update JIRA ticket
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { ticket_key, url, summary, status } = req.body;
@@ -1100,7 +1250,7 @@ app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin')
});
// Delete JIRA ticket
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
@@ -1112,24 +1262,66 @@ app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admi
return res.status(404).json({ error: 'JIRA ticket not found.' });
}
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
if (deleteErr) {
console.error('Error deleting JIRA ticket:', deleteErr);
return res.status(500).json({ error: 'Internal server error.' });
// Admin bypasses all delete restrictions
if (req.user.group === 'Admin') {
return performJiraDelete();
}
// 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 ticketKey = ticket.ticket_key;
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 ?`,
[`%${ticketKey}%`],
(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(ticketKey);
});
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performJiraDelete();
}
);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_delete',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip
function performJiraDelete() {
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
if (deleteErr) {
console.error('Error deleting JIRA ticket:', deleteErr);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_delete',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip
});
res.json({ message: 'JIRA ticket deleted successfully' });
});
res.json({ message: 'JIRA ticket deleted successfully' });
});
}
});
});