diff --git a/backend/migrations/add_user_groups.js b/backend/migrations/add_user_groups.js index 07d47a1..ada4a1b 100644 --- a/backend/migrations/add_user_groups.js +++ b/backend/migrations/add_user_groups.js @@ -78,8 +78,35 @@ function runMigration(db) { (err) => { if (err) { reject(err); return; } console.log('✓ Created idx_users_user_group index'); - console.log('Migration complete!'); - resolve(); + + // Add CHECK constraint via trigger (SQLite can't ALTER TABLE ADD CONSTRAINT) + db.run( + `CREATE TRIGGER IF NOT EXISTS check_user_group_insert + BEFORE INSERT ON users + FOR EACH ROW + WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only') + BEGIN + SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only'); + END`, + (err) => { + if (err) { reject(err); return; } + db.run( + `CREATE TRIGGER IF NOT EXISTS check_user_group_update + BEFORE UPDATE OF user_group ON users + FOR EACH ROW + WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only') + BEGIN + SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only'); + END`, + (err) => { + if (err) { reject(err); return; } + console.log('✓ Created user_group validation triggers'); + console.log('Migration complete!'); + resolve(); + } + ); + } + ); } ); } diff --git a/backend/routes/archerTickets.js b/backend/routes/archerTickets.js index d185ec0..8474a8e 100644 --- a/backend/routes/archerTickets.js +++ b/backend/routes/archerTickets.js @@ -89,8 +89,8 @@ function createArcherTicketsRouter(db) { logAudit(db, { userId: req.user.id, action: 'CREATE_ARCHER_TICKET', - targetType: 'archer_ticket', - targetId: this.lastID, + entityType: 'archer_ticket', + entityId: String(this.lastID), details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, ipAddress: req.ip }); @@ -172,8 +172,8 @@ function createArcherTicketsRouter(db) { logAudit(db, { userId: req.user.id, action: 'UPDATE_ARCHER_TICKET', - targetType: 'archer_ticket', - targetId: id, + entityType: 'archer_ticket', + entityId: String(id), details: { before: existing, changes: req.body }, ipAddress: req.ip }); @@ -195,8 +195,8 @@ function createArcherTicketsRouter(db) { logAudit(db, { userId: req.user.id, action: 'DELETE_ARCHER_TICKET', - targetType: 'archer_ticket', - targetId: id, + entityType: 'archer_ticket', + entityId: String(id), details: { deleted: ticket }, ipAddress: req.ip }); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 1b641c3..4f9a973 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -2,6 +2,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); +const { requireAuth, requireGroup } = require('../middleware/auth'); function createAuthRouter(db, logAudit) { const router = express.Router(); @@ -220,12 +221,7 @@ function createAuthRouter(db, logAudit) { }); // Clean up expired sessions (admin only) - router.post('/cleanup-sessions', async (req, res) => { - // Basic auth check - require a valid session to call this - const sessionId = req.cookies?.session_id; - if (!sessionId) { - return res.status(401).json({ error: 'Authentication required' }); - } + router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => { try { await new Promise((resolve, reject) => { db.run( diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 032be7c..41a6432 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -520,7 +520,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { // Add a note to a (hostname, metric_id) pair. // Body: { hostname, metric_id, note } // ----------------------------------------------------------------------- - router.post('/notes', async (req, res) => { + router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { hostname, metric_id, note } = req.body; if (!hostname || typeof hostname !== 'string' || hostname.length > 300) { diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index dc7ea2f..10a7029 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -829,7 +829,7 @@ function createIvantiFindingsRouter(db, requireAuth) { }); // POST /sync — trigger immediate sync, return fresh state - router.post('/sync', async (req, res) => { + router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { await syncFindings(db); try { res.json(await readStateWithNotes(db)); @@ -934,7 +934,7 @@ function createIvantiFindingsRouter(db, requireAuth) { }); // PUT /:findingId/note — save or update a note (max 255 chars enforced here) - router.put('/:findingId/note', (req, res) => { + router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => { const { findingId } = req.params; const note = String(req.body.note || '').slice(0, 255); diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 3d4adf1..148d924 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -1,5 +1,6 @@ // routes/ivantiTodoQueue.js const express = require('express'); +const { requireGroup } = require('../middleware/auth'); const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; const VALID_STATUSES = ['pending', 'complete']; @@ -36,7 +37,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { // POST /api/ivanti/todo-queue // Add a finding to the queue - router.post('/', requireAuth(db), (req, res) => { + router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body; if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { @@ -86,7 +87,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { // PUT /api/ivanti/todo-queue/:id // Update vendor, workflow_type, or status — scoped to current user - router.put('/:id', requireAuth(db), (req, res) => { + router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { id } = req.params; const { vendor, workflow_type, status } = req.body; @@ -162,7 +163,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { // DELETE /api/ivanti/todo-queue/completed // Bulk-delete all completed items for the current user // IMPORTANT: This route must be registered BEFORE DELETE /:id - router.delete('/completed', requireAuth(db), (req, res) => { + router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { db.run( "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", [req.user.id], @@ -178,7 +179,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { // DELETE /api/ivanti/todo-queue/:id // Delete a single item — scoped to current user - router.delete('/:id', requireAuth(db), (req, res) => { + router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { id } = req.params; db.get( diff --git a/backend/routes/ivantiWorkflows.js b/backend/routes/ivantiWorkflows.js index 3937947..f7489fe 100644 --- a/backend/routes/ivantiWorkflows.js +++ b/backend/routes/ivantiWorkflows.js @@ -5,6 +5,7 @@ const express = require('express'); const https = require('https'); +const { requireGroup } = require('../middleware/auth'); const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -259,7 +260,7 @@ function createIvantiWorkflowsRouter(db, requireAuth) { }); // POST /sync — trigger an immediate sync, await completion, return fresh state - router.post('/sync', async (req, res) => { + router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { await syncWorkflows(db); try { res.json(await readState(db)); diff --git a/backend/routes/knowledgeBase.js b/backend/routes/knowledgeBase.js index e2f403a..2c27968 100644 --- a/backend/routes/knowledgeBase.js +++ b/backend/routes/knowledgeBase.js @@ -132,16 +132,15 @@ function createKnowledgeBaseRouter(db, upload) { } // Log audit entry - logAudit( - db, - req.user.id, - req.user.username, - 'CREATE_KB_ARTICLE', - 'knowledge_base', - this.lastID, - JSON.stringify({ title: title.trim(), filename: sanitizedName }), - req.ip - ); + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'CREATE_KB_ARTICLE', + entityType: 'knowledge_base', + entityId: String(this.lastID), + details: { title: title.trim(), filename: sanitizedName }, + ipAddress: req.ip + }); res.json({ success: true, @@ -232,16 +231,15 @@ function createKnowledgeBaseRouter(db, upload) { } // Log audit entry - logAudit( - db, - req.user.id, - req.user.username, - 'VIEW_KB_ARTICLE', - 'knowledge_base', - id, - JSON.stringify({ filename: row.file_name }), - req.ip - ); + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'VIEW_KB_ARTICLE', + entityType: 'knowledge_base', + entityId: String(id), + details: { filename: row.file_name }, + ipAddress: req.ip + }); // Determine content type for inline display let contentType = row.file_type || 'application/octet-stream'; @@ -284,16 +282,15 @@ function createKnowledgeBaseRouter(db, upload) { } // Log audit entry - logAudit( - db, - req.user.id, - req.user.username, - 'DOWNLOAD_KB_ARTICLE', - 'knowledge_base', - id, - JSON.stringify({ filename: row.file_name }), - req.ip - ); + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'DOWNLOAD_KB_ARTICLE', + entityType: 'knowledge_base', + entityId: String(id), + details: { filename: row.file_name }, + ipAddress: req.ip + }); res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`); @@ -305,7 +302,7 @@ function createKnowledgeBaseRouter(db, upload) { 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 = ?'; + const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?'; db.get(sql, [id], (err, row) => { if (err) { @@ -317,6 +314,11 @@ function createKnowledgeBaseRouter(db, upload) { return res.status(404).json({ error: 'Article not found' }); } + // Ownership check: Standard_User can only delete articles they created + if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) { + return res.status(403).json({ error: 'You can only delete resources you created' }); + } + // Delete database record db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => { if (err) { @@ -330,16 +332,15 @@ function createKnowledgeBaseRouter(db, upload) { } // Log audit entry - logAudit( - db, - req.user.id, - req.user.username, - 'DELETE_KB_ARTICLE', - 'knowledge_base', - id, - JSON.stringify({ title: row.title }), - req.ip - ); + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'DELETE_KB_ARTICLE', + entityType: 'knowledge_base', + entityId: String(id), + details: { title: row.title }, + ipAddress: req.ip + }); res.json({ success: true }); }); diff --git a/backend/server.js b/backend/server.js index 9b7e36b..a265ac2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -846,6 +846,59 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use return res.status(403).json({ error: 'You can only delete resources you created' }); } + // Cascade/compliance check for Standard_User + if (req.user.group === 'Standard_User') { + return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => { + if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); } + + db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => { + 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.' }); } + + const allTickets = [ + ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), + ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) + ]; + + if (allTickets.length === 0) { + return doSingleCveDelete(req, res, id, cve); + } + + const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?'); + const likeParams = allTickets.map(t => `%${t.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 (${likeConditions.join(' OR ')})`, + likeParams, + (compErr, compLinks) => { + 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 hasLink = (compLinks || []).some(cl => { + const json = cl.extra_json || ''; + return allTickets.some(t => json.includes(t.key)); + }); + + if (hasLink) { + return res.status(403).json({ + error: 'CVE deletion blocked: associated ticket linked to compliance report', + cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' } + }); + } + + return doSingleCveDelete(req, res, id, cve); + } + ); + }); + }); + } + + doSingleCveDelete(req, res, id, cve); + }); + + function doSingleCveDelete(req, res, id, cve) { // 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); @@ -892,7 +945,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use }); }); }); - }); + } }); // ========== DOCUMENT ENDPOINTS ========== diff --git a/frontend/src/components/pages/ExportsPage.js b/frontend/src/components/pages/ExportsPage.js index 42da1ea..08ea97a 100644 --- a/frontend/src/components/pages/ExportsPage.js +++ b/frontend/src/components/pages/ExportsPage.js @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const EXC_PATTERN = /EXC-\d+/i; @@ -217,6 +218,7 @@ function Toggle({ label, checked, onChange, color, colorRgb }) { // Main page // --------------------------------------------------------------------------- export default function ExportsPage() { + const { canExport } = useAuth(); const [loading, setLoading] = useState(null); const [error, setError] = useState(null); const [cveStatus, setCveStatus] = useState(''); @@ -333,6 +335,15 @@ export default function ExportsPage() { // ---- Render ---- + if (!canExport()) { + return ( +
+ +

You do not have permission to export data.

+
+ ); + } + return (