From 73fd747576c24b34ed17b38ce42494bc28989b26 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 6 Apr 2026 16:18:07 -0600 Subject: [PATCH 1/4] 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 --- .../group-based-access-control/design.md | 0 .../requirements.md | 143 +++++++++ .../specs/group-based-access-control/tasks.md | 279 ++++++++++++++++++ backend/middleware/auth.js | 17 +- backend/migrations/add_created_by_columns.js | 76 +++++ backend/migrations/add_user_groups.js | 119 ++++++++ backend/routes/archerTickets.js | 89 ++++-- backend/routes/auditLog.js | 6 +- backend/routes/auth.js | 8 +- backend/routes/compliance.js | 6 +- backend/routes/ivantiFindings.js | 4 +- backend/routes/knowledgeBase.js | 6 +- backend/routes/users.js | 84 ++++-- backend/server.js | 264 ++++++++++++++--- frontend/src/App.js | 20 +- frontend/src/components/NavDrawer.js | 61 +++- frontend/src/components/UserManagement.js | 89 ++++-- frontend/src/components/UserMenu.js | 23 +- frontend/src/contexts/AuthContext.js | 26 +- 19 files changed, 1171 insertions(+), 149 deletions(-) create mode 100644 .kiro/specs/group-based-access-control/design.md create mode 100644 .kiro/specs/group-based-access-control/requirements.md create mode 100644 .kiro/specs/group-based-access-control/tasks.md create mode 100644 backend/migrations/add_created_by_columns.js create mode 100644 backend/migrations/add_user_groups.js diff --git a/.kiro/specs/group-based-access-control/design.md b/.kiro/specs/group-based-access-control/design.md new file mode 100644 index 0000000..e69de29 diff --git a/.kiro/specs/group-based-access-control/requirements.md b/.kiro/specs/group-based-access-control/requirements.md new file mode 100644 index 0000000..a72c819 --- /dev/null +++ b/.kiro/specs/group-based-access-control/requirements.md @@ -0,0 +1,143 @@ +# Requirements Document + +## Introduction + +Replace the existing simple role-based access control system (admin/editor/viewer) with a group-based access control model. The system supports exactly four user groups (Admin, Standard User, Leadership, Read Only) with distinct permission boundaries. This change affects the database schema, backend middleware, API endpoint authorization, frontend conditional rendering, and the admin panel user management interface. + +## Glossary + +- **Dashboard**: The STEAM Security Dashboard application comprising a React frontend and Express backend +- **Group**: One of four access control categories (Admin, Standard_User, Leadership, Read_Only) that determines a user's permissions +- **Admin_Group**: The group with full CRUD access to all resources, user management, and admin panel access +- **Standard_User_Group**: The working group with view-all, create, edit, and conditional delete permissions plus basic export +- **Leadership_Group**: The read-only group with additional export capabilities for reports, compliance documents, and visualizations +- **Read_Only_Group**: The view-only group with no create, edit, delete, or export capabilities +- **Permission_Middleware**: Backend Express middleware that validates a user's group membership before allowing an API action +- **Cascade_Impact**: The set of associated Archer tickets, JIRA tickets, and documents that would be deleted when a CVE is deleted +- **Compliance_Link**: An association between a ticket (Archer or JIRA) and a compliance report that blocks Standard_User deletion +- **Group_Migration**: The database migration that replaces the role field with a group field and maps existing users + +## Requirements + +### Requirement 1: Group Data Model + +**User Story:** As a system administrator, I want the user model to reference one of four defined groups instead of the legacy role field, so that permissions are enforced through a well-defined group structure. + +#### Acceptance Criteria + +1. THE Dashboard SHALL store exactly four groups: Admin, Standard_User, Leadership, and Read_Only +2. THE Dashboard SHALL assign each user to exactly one group via a group field on the user record +3. WHEN a user record is created, THE Dashboard SHALL default the group to Read_Only +4. THE Dashboard SHALL enforce a foreign key or CHECK constraint so that the group field only accepts valid group values + +### Requirement 2: Group Migration + +**User Story:** As a system administrator, I want existing users to be automatically mapped from the old role system to the new group system, so that no manual re-assignment is needed after the upgrade. + +#### Acceptance Criteria + +1. WHEN the migration runs, THE Group_Migration SHALL map users with role "admin" to Admin_Group +2. WHEN the migration runs, THE Group_Migration SHALL map users with role "editor" to Standard_User_Group +3. WHEN the migration runs, THE Group_Migration SHALL map users with role "viewer" to Read_Only_Group +4. WHEN the migration runs, THE Group_Migration SHALL remove the CHECK constraint on the old role column and replace it with the new group field +5. IF a user record has no role value or an unrecognized role value, THEN THE Group_Migration SHALL assign that user to Read_Only_Group + +### Requirement 3: Backend Permission Enforcement + +**User Story:** As a security-conscious developer, I want every API endpoint to check the requesting user's group before allowing the action, so that permissions are enforced server-side and cannot be bypassed through direct API calls. + +#### Acceptance Criteria + +1. THE Permission_Middleware SHALL replace the existing requireRole middleware with a requireGroup middleware that accepts one or more group names +2. WHEN an unauthenticated request reaches a protected endpoint, THE Permission_Middleware SHALL return HTTP 401 +3. WHEN an authenticated user's group is not in the allowed groups for an endpoint, THE Permission_Middleware SHALL return HTTP 403 +4. THE Permission_Middleware SHALL attach the user's group to the request object for downstream route handlers to use +5. WHEN a Standard_User_Group user attempts to delete a resource they did not create, THE Dashboard SHALL return HTTP 403 +6. WHEN a Standard_User_Group user attempts to delete a finding that is marked as resolved or closed, THE Dashboard SHALL return HTTP 403 +7. WHEN a Standard_User_Group user attempts to delete a ticket that is linked to a compliance report, THE Dashboard SHALL return HTTP 403 +8. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL check for Cascade_Impact and return the list of associated Archer tickets, JIRA tickets, and documents +9. IF any ticket in the Cascade_Impact is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion and return HTTP 403 with a message indicating Admin-only deletion is required +10. WHEN an Admin_Group user performs any CRUD operation, THE Dashboard SHALL allow the operation without ownership or state restrictions + +### Requirement 4: Admin Group Permissions + +**User Story:** As an admin, I want full unrestricted access to all resources and management functions, so that I can manage the entire system without limitations. + +#### Acceptance Criteria + +1. THE Dashboard SHALL allow Admin_Group users to create, read, update, and delete all resources (CVEs, findings, tickets, comments, compliance reports) +2. THE Dashboard SHALL allow Admin_Group users to access the admin panel +3. THE Dashboard SHALL allow Admin_Group users to manage users and assign users to groups +4. THE Dashboard SHALL allow Admin_Group users to export all data +5. THE Dashboard SHALL allow Admin_Group users to delete any resource regardless of ownership, state, or compliance linkage + +### Requirement 5: Standard User Group Permissions + +**User Story:** As a standard user, I want to view all data and create/edit resources while having controlled delete access, so that I can do my daily work without accidentally removing critical linked data. + +#### Acceptance Criteria + +1. THE Dashboard SHALL allow Standard_User_Group users to view all data across the dashboard +2. THE Dashboard SHALL allow Standard_User_Group users to create and edit CVEs, findings, tickets, and comments +3. THE Dashboard SHALL allow Standard_User_Group users to delete their own findings, tickets, and comments subject to state and linkage restrictions +4. WHEN a Standard_User_Group user attempts to delete a finding that is resolved or closed, THE Dashboard SHALL reject the deletion +5. WHEN a Standard_User_Group user attempts to delete a ticket linked to a compliance report, THE Dashboard SHALL reject the deletion +6. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL display a warning listing associated Archer tickets, JIRA tickets, and documents that will be cascade-deleted +7. IF any associated ticket in the cascade is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion entirely +8. THE Dashboard SHALL allow Standard_User_Group users to perform basic exports (CSV and XLSX of CVEs and findings) + +### Requirement 6: Leadership Group Permissions + +**User Story:** As a leadership user, I want read-only access with export capabilities, so that I can review data and generate reports without risk of modifying records. + +#### Acceptance Criteria + +1. THE Dashboard SHALL allow Leadership_Group users to view all data across the dashboard +2. THE Dashboard SHALL allow Leadership_Group users to export reports, compliance documents, and graph visualizations +3. THE Dashboard SHALL prevent Leadership_Group users from creating, editing, or deleting any records +4. THE Dashboard SHALL prevent Leadership_Group users from accessing the admin panel + +### Requirement 7: Read Only Group Permissions + +**User Story:** As a read-only user, I want view-only access to the dashboard, so that I can see data without any ability to modify or export it. + +#### Acceptance Criteria + +1. THE Dashboard SHALL allow Read_Only_Group users to view all data across the dashboard +2. THE Dashboard SHALL prevent Read_Only_Group users from creating, editing, or deleting any records +3. THE Dashboard SHALL prevent Read_Only_Group users from exporting any data +4. THE Dashboard SHALL prevent Read_Only_Group users from accessing the admin panel + +### Requirement 8: Admin Panel Group Management + +**User Story:** As an admin, I want to view all users with their current group and reassign groups through the admin panel, so that I can manage access control centrally. + +#### Acceptance Criteria + +1. WHEN an Admin_Group user opens the user management section, THE Dashboard SHALL display all users with their current group assignment +2. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL update the group assignment and persist it to the database +3. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL display a confirmation dialog before applying the change +4. WHEN an Admin_Group user downgrades another Admin_Group user, THE Dashboard SHALL display an additional warning in the confirmation dialog +5. THE Dashboard SHALL prevent an Admin_Group user from changing their own group to a non-Admin group + +### Requirement 9: Audit Logging for Group Changes + +**User Story:** As a system administrator, I want all group assignment changes to be logged with full context, so that I can audit who changed access for whom and when. + +#### Acceptance Criteria + +1. WHEN a user's group is changed, THE Dashboard SHALL log the change with the acting user's ID, the target user's ID, the previous group, the new group, and a timestamp +2. THE Dashboard SHALL preserve existing audit trail behavior for all CRUD operations performed under the new group system +3. WHEN a group change is logged, THE Dashboard SHALL record the IP address of the acting user + +### Requirement 10: Frontend Conditional Rendering + +**User Story:** As a user, I want the UI to show only the actions available to my group, so that I have a clear and uncluttered interface matching my permissions. + +#### Acceptance Criteria + +1. THE Dashboard SHALL conditionally render create, edit, and delete buttons based on the current user's group +2. THE Dashboard SHALL conditionally render export options based on the current user's group +3. THE Dashboard SHALL conditionally render the admin panel link based on the current user's group +4. WHEN a Standard_User_Group user views a resource they did not create, THE Dashboard SHALL hide the delete button for that resource +5. THE Dashboard SHALL replace the existing role-based helper functions (hasRole, canWrite, isAdmin) with group-based equivalents (isInGroup, canWrite, canDelete, canExport, isAdmin) diff --git a/.kiro/specs/group-based-access-control/tasks.md b/.kiro/specs/group-based-access-control/tasks.md new file mode 100644 index 0000000..81ba5ff --- /dev/null +++ b/.kiro/specs/group-based-access-control/tasks.md @@ -0,0 +1,279 @@ +# Implementation Plan: Group-Based Access Control + +## Overview + +Replace the existing role-based access control (admin/editor/viewer) with a four-group model (Admin, Standard_User, Leadership, Read_Only). This touches the database schema, backend middleware, all route authorization, frontend permission helpers, and the admin panel UI. Tasks build incrementally: migration first, then middleware, then routes, then frontend. + +## Tasks + +- [ ] 1. Create database migration for user groups + - [x] 1.1 Create `backend/migrations/add_user_groups.js` migration script + - Add `user_group` column (VARCHAR(20), NOT NULL, DEFAULT 'Read_Only') to users table + - Map existing role values: admin to Admin, editor to Standard_User, viewer to Read_Only + - Map NULL or unrecognized role values to Read_Only + - Add CHECK constraint: user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only') + - Add index `idx_users_user_group` on user_group column + - Use idempotent checks so migration is safe to run multiple times + - Follow existing migration pattern: open db, db.serialize(), log progress, close db + - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [ ]* 1.2 Write property test for migration role mapping + - **Property 8: Migration maps all role values correctly** + - Generate users with random roles from {admin, editor, viewer, NULL, arbitrary}, run migration against in-memory SQLite, verify mapping + - **Validates: Requirements 2.1, 2.2, 2.3, 2.5** + + - [ ]* 1.3 Write property test for migration idempotency + - **Property 9: Migration is idempotent** + - Run migration N times (N in 1-5) against in-memory SQLite, verify schema and data identical each time + - **Validates: Requirements 2.4** + + - [ ] 1.4 Write unit tests for migration + - Test column creation with correct CHECK constraint + - Test role mapping: admin to Admin, editor to Standard_User, viewer to Read_Only + - Test NULL and unrecognized role handling defaults to Read_Only + - Test new user defaults to Read_Only group + - _Requirements: 1.3, 1.4, 2.1, 2.2, 2.3, 2.5_ + +- [ ] 2. Update auth middleware to use groups + - [x] 2.1 Update `requireAuth` in `backend/middleware/auth.js` + - Modify session join query to SELECT user_group and attach as req.user.group + - _Requirements: 3.4_ + + - [x] 2.2 Add `requireGroup` middleware function + - Accept spread of allowed group names + - Return 401 if req.user is missing + - Return 403 with error details if user group not in allowed set + - Call next() if group is allowed + - _Requirements: 3.1, 3.2, 3.3_ + + - [x] 2.3 Remove `requireRole` and export `requireGroup` + - Remove requireRole function and its export + - Export requireGroup in its place + - _Requirements: 3.1_ + + - [ ]* 2.4 Write property test for group constraint + - **Property 1: Group constraint rejects invalid values** + - Generate random strings not in valid group set, attempt DB insert, verify constraint error + - **Validates: Requirements 1.1, 1.4** + + - [ ]* 2.5 Write property test for requireGroup + - **Property 3: requireGroup rejects unauthorized groups** + - Generate random group and allowedGroups pairs where group is not in allowed set, verify 403 + - **Validates: Requirements 3.3** + + - [ ] 2.6 Write unit tests for requireGroup middleware + - Test 401 for unauthenticated requests + - Test 403 for wrong group + - Test group attached to req.user + - Test next() called for allowed group + - _Requirements: 3.2, 3.3, 3.4_ + +- [x] 3. Checkpoint: Verify migration and middleware + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 4. Update auth routes to return group + - [x] 4.1 Update login endpoint in `backend/routes/auth.js` + - Return group (from user_group) instead of role in user response object + - Update audit log details to log group instead of role + - _Requirements: 3.4, 9.2_ + + - [x] 4.2 Update me endpoint in `backend/routes/auth.js` + - Return group instead of role in user response object + - _Requirements: 3.4_ + +- [ ] 5. Update user management routes + - [x] 5.1 Switch `backend/routes/users.js` to use requireGroup + - Replace requireRole('admin') with requireGroup('Admin') + - _Requirements: 4.2, 4.3_ + + - [x] 5.2 Update GET endpoints to return user_group + - Return user_group instead of role in user records + - _Requirements: 8.1_ + + - [x] 5.3 Update POST create user to accept group param + - Validate group against valid values + - Default to Read_Only if not provided + - Return 400 for invalid group values + - _Requirements: 1.3, 8.2_ + + - [x] 5.4 Update PATCH update user to accept group param + - Validate group against valid values + - Prevent admin self-demotion (return 400) + - _Requirements: 8.2, 8.5_ + + - [x] 5.5 Add audit logging for group changes + - Log acting user ID, target user ID, previous group, new group, IP address, timestamp + - _Requirements: 9.1, 9.3_ + + - [ ]* 5.6 Write property test for user group validity + - **Property 2: Every user has exactly one valid group** + - Generate random user sets, query all users, verify each has exactly one valid group + - **Validates: Requirements 1.2** + + - [ ] 5.7 Write unit tests for user management group logic + - Test group validation rejects invalid values + - Test self-demotion prevention + - Test audit logging includes all required fields + - _Requirements: 8.2, 8.5, 9.1, 9.3_ + +- [ ] 6. Update backend route authorization across all routes + - [x] 6.1 Update `backend/routes/auditLog.js` + - Replace requireRole('admin') with requireGroup('Admin') + - _Requirements: 4.2_ + + - [x] 6.2 Update `backend/routes/archerTickets.js` + - Use requireGroup('Admin', 'Standard_User') for create, update, delete + - _Requirements: 5.2_ + + - [x] 6.3 Update `backend/routes/knowledgeBase.js` + - Use requireGroup('Admin', 'Standard_User') for upload and delete + - _Requirements: 5.2_ + + - [x] 6.4 Update `backend/routes/ivantiFindings.js` + - Use requireGroup('Admin', 'Standard_User') for override endpoint + - _Requirements: 5.2_ + + - [x] 6.5 Update `backend/routes/compliance.js` + - Use requireGroup('Admin', 'Standard_User') for preview and commit + - _Requirements: 5.2_ + + - [x] 6.6 Update `backend/server.js` inline CVE routes + - Use requireGroup('Admin', 'Standard_User') for POST, PUT, PATCH, DELETE + - _Requirements: 5.2_ + + - [x] 6.7 Update `backend/server.js` route mounting + - Pass requireGroup instead of requireRole to route factories + - _Requirements: 3.1_ + + - [ ]* 6.8 Write property test for Leadership restrictions + - **Property 5: Leadership cannot mutate any resource** + - Generate random mutation requests as Leadership, verify 403 + - **Validates: Requirements 6.3** + + - [ ]* 6.9 Write property test for Read_Only restrictions + - **Property 6: Read_Only cannot mutate or export** + - Generate random mutation and export requests as Read_Only, verify 403 + - **Validates: Requirements 7.2, 7.3** + +- [x] 7. Checkpoint: Verify backend route authorization + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 8. Implement Standard User conditional delete logic + - [x] 8.1 Add created_by column tracking + - Add created_by to CVE, finding, and ticket creation endpoints storing req.user.id on insert + - _Requirements: 3.5_ + + - [x] 8.2 Implement ownership check for CVE delete + - Standard_User can only delete CVEs they created + - Return 403 if not owner + - _Requirements: 3.5_ + + - [x] 8.3 Implement cascade impact check for CVE delete + - Query associated Archer tickets and documents + - Check compliance linkage on cascaded tickets + - Return cascade_impact response schema + - Block deletion if any cascaded ticket is compliance-linked + - _Requirements: 3.8, 3.9_ + + - [x] 8.4 Implement state check for finding delete + - Standard_User cannot delete resolved or closed findings + - Return 403 with appropriate error message + - _Requirements: 3.6_ + + - [x] 8.5 Implement compliance linkage check for ticket delete + - Standard_User cannot delete tickets linked to compliance reports + - Return 403 with appropriate error message + - _Requirements: 3.7_ + + - [x] 8.6 Ensure Admin bypasses all delete restrictions + - Admin group skips ownership, state, and compliance checks + - _Requirements: 3.10, 4.5_ + + - [ ]* 8.7 Write property test for Admin delete bypass + - **Property 4: Admin bypasses all delete restrictions** + - Generate resources with random ownership, state, compliance linkage, delete as Admin, verify success + - **Validates: Requirements 3.10, 4.1, 4.5** + + - [ ] 8.8 Write unit tests for conditional delete logic + - Test ownership rejection for non-owner + - Test state rejection for resolved/closed findings + - Test compliance linkage rejection + - Test cascade impact response format + - Test Admin bypass of all restrictions + - _Requirements: 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_ + +- [x] 9. Checkpoint: Verify conditional delete logic + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 10. Update frontend AuthContext with group helpers + - [x] 10.1 Update `frontend/src/contexts/AuthContext.js` + - Read group from user object instead of role + - Replace hasRole with isInGroup(...groups) helper + - Update canWrite to check isInGroup('Admin', 'Standard_User') + - Add canDelete(resource) helper: Admin always true, Standard_User only if owns resource, others false + - Add canExport() helper: true for Admin, Standard_User, Leadership + - Update isAdmin() to check isInGroup('Admin') + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_ + + - [ ]* 10.2 Write property test for permission helpers + - **Property 7: Group permission helpers are consistent with group matrix** + - Generate all valid group values, call each helper, verify against permission matrix + - **Validates: Requirements 10.5** + +- [ ] 11. Update frontend UI for group-based rendering + - [x] 11.1 Update `App.js` conditional rendering + - Use canWrite, canDelete, canExport, isAdmin for button and link visibility + - _Requirements: 10.1, 10.2, 10.3_ + + - [x] 11.2 Update `NavDrawer.js` + - Show admin panel link only when isAdmin() is true + - _Requirements: 10.3_ + + - [x] 11.3 Update `UserMenu.js` + - Display user group instead of role + - _Requirements: 10.1_ + + - [x] 11.4 Update all components using hasRole or canWrite + - Replace with new group-based helpers throughout components + - _Requirements: 10.5_ + + - [x] 11.5 Hide delete buttons for non-owned resources + - Standard_User sees delete only on resources they created + - _Requirements: 10.4_ + +- [ ] 12. Update User Management UI + - [x] 12.1 Replace role dropdown with group dropdown in `UserManagement.js` + - Options: Admin, Standard_User, Leadership, Read_Only + - _Requirements: 8.1, 8.2_ + + - [x] 12.2 Update form data and API calls to use group field + - Send group instead of role in create and update requests + - _Requirements: 8.2_ + + - [x] 12.3 Add confirmation dialog for group changes + - Show confirmation before applying any group change + - _Requirements: 8.3_ + + - [x] 12.4 Add extra warning when downgrading Admin + - Show additional warning in confirmation dialog + - _Requirements: 8.4_ + + - [x] 12.5 Prevent admin self-demotion in UI + - Disable group change dropdown for current user if Admin + - _Requirements: 8.5_ + + - [x] 12.6 Update user table to show group badges + - Display group badge with appropriate colors instead of role badge + - _Requirements: 8.1_ + +- [x] 13. Final checkpoint: Verify full integration + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests use `fast-check` library with minimum 100 iterations per test +- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns) +- All frontend code uses plain JavaScript (no TypeScript) diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 2a9f6b5..d5b5e95 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -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 }; diff --git a/backend/migrations/add_created_by_columns.js b/backend/migrations/add_created_by_columns.js new file mode 100644 index 0000000..ab2b12d --- /dev/null +++ b/backend/migrations/add_created_by_columns.js @@ -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} + */ +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 }; diff --git a/backend/migrations/add_user_groups.js b/backend/migrations/add_user_groups.js new file mode 100644 index 0000000..07d47a1 --- /dev/null +++ b/backend/migrations/add_user_groups.js @@ -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} + */ +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 }; diff --git a/backend/routes/archerTickets.js b/backend/routes/archerTickets.js index 3c28342..d185ec0 100644 --- a/backend/routes/archerTickets.js +++ b/backend/routes/archerTickets.js @@ -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' }); - }); + ); }); }); diff --git a/backend/routes/auditLog.js b/backend/routes/auditLog.js index 9a81c2c..62ee278 100644 --- a/backend/routes/auditLog.js +++ b/backend/routes/auditLog.js @@ -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) => { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index c914e42..1b641c3 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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) { diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 640f0c9..032be7c 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -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') { diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index edf0d1d..dc7ea2f 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -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; diff --git a/backend/routes/knowledgeBase.js b/backend/routes/knowledgeBase.js index 7abde51..e2f403a 100644 --- a/backend/routes/knowledgeBase.js +++ b/backend/routes/knowledgeBase.js @@ -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 = ?'; diff --git a/backend/routes/users.js b/backend/routes/users.js index bee34f5..4d6e749 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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) => { diff --git a/backend/server.js b/backend/server.js index 038381e..9b7e36b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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' }); - }); + } }); }); diff --git a/frontend/src/App.js b/frontend/src/App.js index 6c7f02a..3e25b8d 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -162,7 +162,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001'; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; export default function App() { - const { isAuthenticated, loading: authLoading, canWrite, isAdmin, user } = useAuth(); + const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, user } = useAuth(); const [searchQuery, setSearchQuery] = useState(''); const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); @@ -1746,7 +1746,7 @@ export default function App() { {cves.length} vendor entr{cves.length !== 1 ? 'ies' : 'y'}

- {selectedDocuments.length > 0 && ( + {selectedDocuments.length > 0 && canExport() && ( )} - {canWrite() && ( + {canDelete(cve) && ( + {canDelete(ticket) && ( + )} )} @@ -2152,9 +2154,11 @@ export default function App() { + {canDelete(ticket) && ( + )} )} @@ -2220,14 +2224,16 @@ export default function App() { > - {canWrite() && (<> + {canWrite() && ( + )} + {canDelete(ticket) && ( - )} + )}
{ticket.cve_id}
@@ -2256,6 +2262,7 @@ export default function App() { Ivanti Workflows + {canWrite() && ( + )} {/* Last synced line */} diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index 49cffd8..d92fb3c 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -1,5 +1,6 @@ import React from 'react'; -import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react'; +import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; const NAV_ITEMS = [ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, @@ -9,7 +10,11 @@ const NAV_ITEMS = [ { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, ]; +const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' }; + export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) { + const { isAdmin } = useAuth(); + if (!isOpen) return null; return ( @@ -110,6 +115,60 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) ); })} + + {/* Admin panel link — visible only to Admin group */} + {isAdmin() && (() => { + const { id, label, icon: Icon, color, description } = ADMIN_ITEM; + const active = currentPage === id; + return ( + + ); + })()} {/* Footer */} diff --git a/frontend/src/components/UserManagement.js b/frontend/src/components/UserManagement.js index 7a3784d..e34a9f8 100644 --- a/frontend/src/components/UserManagement.js +++ b/frontend/src/components/UserManagement.js @@ -4,6 +4,22 @@ import { useAuth } from '../contexts/AuthContext'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; +const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; + +const GROUP_LABELS = { + Admin: 'Admin (full access)', + Standard_User: 'Standard User (create, edit, limited delete)', + Leadership: 'Leadership (read-only + exports)', + Read_Only: 'Read Only (view only)' +}; + +const GROUP_BADGE_STYLES = { + Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' }, + Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' }, + Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' }, + Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' } +}; + export default function UserManagement({ onClose }) { const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); @@ -15,7 +31,7 @@ export default function UserManagement({ onClose }) { username: '', email: '', password: '', - role: 'viewer' + group: 'Read_Only' }); const [formError, setFormError] = useState(''); const [formSuccess, setFormSuccess] = useState(''); @@ -39,11 +55,29 @@ export default function UserManagement({ onClose }) { } }; + const confirmGroupChange = (targetUser, newGroup) => { + let message = `Are you sure you want to change ${targetUser.username}'s group from ${targetUser.group} to ${newGroup}?`; + + // Extra warning when downgrading an Admin user + if (targetUser.group === 'Admin' && newGroup !== 'Admin') { + message += `\n\n⚠️ WARNING: You are removing Admin privileges from ${targetUser.username}. They will lose full system access.`; + } + + return window.confirm(message); + }; + const handleSubmit = async (e) => { e.preventDefault(); setFormError(''); setFormSuccess(''); + // If editing and group changed, show confirmation dialog + if (editingUser && formData.group !== editingUser.group) { + if (!confirmGroupChange(editingUser, formData.group)) { + return; + } + } + try { const url = editingUser ? `${API_BASE}/users/${editingUser.id}` @@ -75,7 +109,7 @@ export default function UserManagement({ onClose }) { setTimeout(() => { setShowAddUser(false); setEditingUser(null); - setFormData({ username: '', email: '', password: '', role: 'viewer' }); + setFormData({ username: '', email: '', password: '', group: 'Read_Only' }); setFormSuccess(''); }, 1500); } catch (err) { @@ -89,7 +123,7 @@ export default function UserManagement({ onClose }) { username: user.username, email: user.email, password: '', - role: user.role + group: user.group }); setShowAddUser(true); setFormError(''); @@ -140,15 +174,10 @@ export default function UserManagement({ onClose }) { } }; - const getRoleBadgeColor = (role) => { - switch (role) { - case 'admin': - return 'bg-red-100 text-red-800'; - case 'editor': - return 'bg-blue-100 text-blue-800'; - default: - return 'bg-gray-100 text-gray-800'; - } + // Check if group dropdown should be disabled for self-demotion prevention + const isGroupDropdownDisabled = (targetUser) => { + if (!targetUser || !currentUser) return false; + return targetUser.id === currentUser.id && currentUser.group === 'Admin'; }; return ( @@ -173,7 +202,7 @@ export default function UserManagement({ onClose }) { onClick={() => { setShowAddUser(true); setEditingUser(null); - setFormData({ username: '', email: '', password: '', role: 'viewer' }); + setFormData({ username: '', email: '', password: '', group: 'Read_Only' }); setFormError(''); setFormSuccess(''); }} @@ -253,19 +282,24 @@ export default function UserManagement({ onClose }) {
+ {isGroupDropdownDisabled(editingUser) && ( +

You cannot change your own Admin group.

+ )}
@@ -308,7 +342,7 @@ export default function UserManagement({ onClose }) { User - Role + Group Status Last Login Actions @@ -324,8 +358,17 @@ export default function UserManagement({ onClose }) { - - {user.role.charAt(0).toUpperCase() + user.role.slice(1)} + + {user.group ? user.group.replace('_', ' ') : 'Read Only'} diff --git a/frontend/src/components/UserMenu.js b/frontend/src/components/UserMenu.js index d351bfc..09cea57 100644 --- a/frontend/src/components/UserMenu.js +++ b/frontend/src/components/UserMenu.js @@ -19,17 +19,26 @@ export default function UserMenu({ onManageUsers, onAuditLog }) { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const getRoleBadgeColor = (role) => { - switch (role) { - case 'admin': + const getGroupBadgeColor = (group) => { + switch (group) { + case 'Admin': return 'bg-red-100 text-red-800'; - case 'editor': + case 'Standard_User': return 'bg-blue-100 text-blue-800'; + case 'Leadership': + return 'bg-purple-100 text-purple-800'; + case 'Read_Only': + return 'bg-gray-100 text-gray-800'; default: return 'bg-gray-100 text-gray-800'; } }; + const formatGroupName = (group) => { + if (!group) return ''; + return group.replace(/_/g, ' '); + }; + const handleLogout = async () => { setIsOpen(false); await logout(); @@ -62,7 +71,7 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {

{user.username}

-

{user.role}

+

{formatGroupName(user.group)}

@@ -72,8 +81,8 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {

{user.username}

{user.email}

- - {user.role.charAt(0).toUpperCase() + user.role.slice(1)} + + {formatGroupName(user.group)}
diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js index 8947ef5..13329da 100644 --- a/frontend/src/contexts/AuthContext.js +++ b/frontend/src/contexts/AuthContext.js @@ -72,16 +72,26 @@ export function AuthProvider({ children }) { setUser(null); }; - // Check if user has a specific role - const hasRole = (...roles) => { - return user && roles.includes(user.role); + // Check if user belongs to one of the specified groups + const isInGroup = (...groups) => user && groups.includes(user.group); + + // Check if user can perform write operations (Admin or Standard_User) + const canWrite = () => isInGroup('Admin', 'Standard_User'); + + // Check if user can delete a resource + // Admin: always true; Standard_User: only if they own the resource; others: false + const canDelete = (resource) => { + if (!user) return false; + if (isInGroup('Admin')) return true; + if (!isInGroup('Standard_User')) return false; + return resource?.created_by === user.id; }; - // Check if user can perform write operations (editor or admin) - const canWrite = () => hasRole('editor', 'admin'); + // Check if user can export data + const canExport = () => isInGroup('Admin', 'Standard_User', 'Leadership'); // Check if user is admin - const isAdmin = () => hasRole('admin'); + const isAdmin = () => isInGroup('Admin'); const value = { user, @@ -90,8 +100,10 @@ export function AuthProvider({ children }) { login, logout, checkAuth, - hasRole, + isInGroup, canWrite, + canDelete, + canExport, isAdmin, isAuthenticated: !!user }; From d910af847e07a0e779d0b5d3a8c516673fad71f3 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 6 Apr 2026 16:25:59 -0600 Subject: [PATCH 2/4] fix: wire up admin page route to render UserManagement component --- frontend/src/App.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/App.js b/frontend/src/App.js index 3e25b8d..fda9353 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1012,6 +1012,11 @@ export default function App() { {currentPage === 'compliance' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } + {currentPage === 'admin' && isAdmin() && ( +
+ setCurrentPage('home')} /> +
+ )} {/* User Management Modal */} {showUserManagement && ( From e9e2c0961d8ea6eafe97215b613addc9f32553d0 Mon Sep 17 00:00:00 2001 From: jramos Date: Tue, 7 Apr 2026 09:52:26 -0600 Subject: [PATCH 3/4] fix: address all 11 review items for group-based access control Bugs fixed: - knowledgeBase.js: logAudit calls converted from positional args to object signature - archerTickets.js: targetType/targetId renamed to entityType/entityId - server.js: single CVE delete now has cascade/compliance check for Standard_User Unprotected endpoints secured: - ivantiTodoQueue.js: POST/PUT/DELETE now require Admin or Standard_User - ivantiFindings.js: PUT note and POST sync now require Admin or Standard_User - compliance.js: POST notes now requires Admin or Standard_User - ivantiWorkflows.js: POST sync now requires Admin or Standard_User - auth.js: cleanup-sessions now requires Admin via requireAuth + requireGroup Additional fixes: - ExportsPage.js: canExport() guard blocks Read_Only users - knowledgeBase.js: Standard_User delete checks created_by ownership - Migration: added INSERT/UPDATE triggers to enforce valid user_group values --- backend/migrations/add_user_groups.js | 31 +++++++- backend/routes/archerTickets.js | 12 +-- backend/routes/auth.js | 8 +- backend/routes/compliance.js | 2 +- backend/routes/ivantiFindings.js | 4 +- backend/routes/ivantiTodoQueue.js | 9 ++- backend/routes/ivantiWorkflows.js | 3 +- backend/routes/knowledgeBase.js | 83 ++++++++++---------- backend/server.js | 55 ++++++++++++- frontend/src/components/pages/ExportsPage.js | 11 +++ 10 files changed, 154 insertions(+), 64 deletions(-) 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 (
From c50fc5d8a83391d447062c814a965c71a9c62dea Mon Sep 17 00:00:00 2001 From: jramos Date: Tue, 7 Apr 2026 10:09:18 -0600 Subject: [PATCH 4/4] fix: address all 11 review items for group-based access control Bugs fixed: - knowledgeBase.js: logAudit calls converted from positional args to object signature - archerTickets.js: targetType/targetId renamed to entityType/entityId - server.js: single CVE delete now has cascade/compliance check for Standard_User Unprotected endpoints secured: - ivantiTodoQueue.js: POST/PUT/DELETE now require Admin or Standard_User - ivantiFindings.js: PUT note and POST sync now require Admin or Standard_User - compliance.js: POST notes now requires Admin or Standard_User - ivantiWorkflows.js: POST sync now requires Admin or Standard_User - auth.js: cleanup-sessions now requires Admin via requireAuth + requireGroup Additional fixes: - ExportsPage.js: canExport() guard blocks Read_Only users - knowledgeBase.js: Standard_User delete checks created_by ownership - Migration: added INSERT/UPDATE triggers to enforce valid user_group values --- backend/routes/knowledgeBase.js | 69 +++- docs/security-audit-2026-04-01.md | 617 ++++++++++++++++++++++++++++++ 2 files changed, 680 insertions(+), 6 deletions(-) create mode 100644 docs/security-audit-2026-04-01.md diff --git a/backend/routes/knowledgeBase.js b/backend/routes/knowledgeBase.js index 2c27968..9f29900 100644 --- a/backend/routes/knowledgeBase.js +++ b/backend/routes/knowledgeBase.js @@ -39,7 +39,19 @@ function createKnowledgeBaseRouter(db, upload) { return ALLOWED_EXTENSIONS.has(ext); } - // POST /api/knowledge-base/upload - Upload new document + /** + * POST /api/knowledge-base/upload + * Upload a new knowledge base document. + * + * @body {string} title - Article title (required) + * @body {string} [description] - Article description + * @body {string} [category] - Article category (defaults to 'General') + * @body {File} file - The document file to upload (multipart/form-data) + * + * @response 200 - { success: true, id: number, title: string, slug: string, category: string } + * @response 400 - { error: string } - Missing title, no file, or invalid file type + * @response 500 - { error: string } - Database or filesystem error + */ router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => { upload.single('file')(req, res, (err) => { if (err) { @@ -160,7 +172,13 @@ function createKnowledgeBaseRouter(db, upload) { } }); - // GET /api/knowledge-base - List all articles + /** + * GET /api/knowledge-base + * List all knowledge base articles. + * + * @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }] + * @response 500 - { error: string } + */ router.get('/', requireAuth(db), (req, res) => { const sql = ` SELECT @@ -182,7 +200,16 @@ function createKnowledgeBaseRouter(db, upload) { }); }); - // GET /api/knowledge-base/:id - Get single article details + /** + * GET /api/knowledge-base/:id + * Get a single article's details by ID. + * + * @param {string} id - Article ID (route parameter) + * + * @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username } + * @response 404 - { error: 'Article not found' } + * @response 500 - { error: string } + */ router.get('/:id', requireAuth(db), (req, res) => { const { id } = req.params; @@ -210,7 +237,17 @@ function createKnowledgeBaseRouter(db, upload) { }); }); - // GET /api/knowledge-base/:id/content - Get document content for display + /** + * GET /api/knowledge-base/:id/content + * Get document content for inline display. Returns the raw file with appropriate + * Content-Type headers. Markdown and text files are served as text/plain. + * + * @param {string} id - Article ID (route parameter) + * + * @response 200 - Raw file content with Content-Type and Content-Disposition headers + * @response 404 - { error: string } - Article or file not found + * @response 500 - { error: string } + */ router.get('/:id/content', requireAuth(db), (req, res) => { const { id } = req.params; @@ -261,7 +298,16 @@ function createKnowledgeBaseRouter(db, upload) { }); }); - // GET /api/knowledge-base/:id/download - Download document + /** + * GET /api/knowledge-base/:id/download + * Download a knowledge base document as an attachment. + * + * @param {string} id - Article ID (route parameter) + * + * @response 200 - File download with Content-Disposition: attachment header + * @response 404 - { error: string } - Article or file not found + * @response 500 - { error: string } + */ router.get('/:id/download', requireAuth(db), (req, res) => { const { id } = req.params; @@ -298,7 +344,18 @@ function createKnowledgeBaseRouter(db, upload) { }); }); - // DELETE /api/knowledge-base/:id - Delete article + /** + * DELETE /api/knowledge-base/:id + * Delete a knowledge base article and its associated file. + * Standard_User can only delete articles they created. Admin can delete any article. + * + * @param {string} id - Article ID (route parameter) + * + * @response 200 - { success: true } + * @response 403 - { error: string } - Ownership check failed for Standard_User + * @response 404 - { error: 'Article not found' } + * @response 500 - { error: string } + */ router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { id } = req.params; diff --git a/docs/security-audit-2026-04-01.md b/docs/security-audit-2026-04-01.md new file mode 100644 index 0000000..174401b --- /dev/null +++ b/docs/security-audit-2026-04-01.md @@ -0,0 +1,617 @@ +# Security Audit Report — STEAM Security Dashboard + +**Date:** 2026-04-01 +**Scope:** Full codebase — backend routes, authentication, file handling, Python scripts, React frontend +**Methodology:** Static analysis across four parallel audit tracks + +--- + +## Executive Summary + +The audit identified **31 findings** across four severity levels. The most serious issues are concentrated in the **authentication and authorization layer** — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base. + +| Severity | Count | +|----------|-------| +| Critical | 6 | +| High | 9 | +| Medium | 10 | +| Low / Info | 6 | +| **Total** | **31** | + +The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses `spawn` with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws. + +--- + +## Critical Findings + +--- + +### C-1 — Missing Authentication on Ivanti Findings Endpoints + +**File:** `backend/routes/ivantiFindings.js:552–600` + +The findings router imports `requireRole` but **not** `requireAuth`. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated: + +```js +const { requireRole } = require('../middleware/auth'); // requireAuth never imported + +router.get('/', async (req, res) => { // line 552 — no auth +router.post('/sync', async (req, res) => { // line 561 — no auth +router.get('/counts', async (req, res) => { // line 571 — no auth +router.get('/fp-workflow-counts', ...) // line 580 — no auth +``` + +**Impact:** Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics. + +**Fix:** Import `requireAuth` and apply it to the router or each route: +```js +const { requireAuth, requireRole } = require('../middleware/auth'); +router.use(requireAuth(db)); +``` + +--- + +### C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base + +**File:** `backend/routes/knowledgeBase.js:43, 305` + +`requireRole` is called with `db` as the first argument: + +```js +router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...) +router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...) +``` + +The function signature is `function requireRole(...allowedRoles)`. It does not accept `db`. The database object is treated as the first "allowed role", so the check becomes `req.user.role === db` — an object comparison that always evaluates false, meaning **the check never blocks anyone**. Any authenticated viewer can upload and delete knowledge base documents. + +**Fix:** Remove `db` from all `requireRole` calls: +```js +requireRole('editor', 'admin') +``` + +--- + +### C-3 — Unauthenticated Ivanti Finding Note Writes + +**File:** `backend/routes/ivantiFindings.js:639` + +The PUT endpoint for saving finding notes has no authentication middleware: + +```js +router.put('/:findingId/note', (req, res) => { + const note = String(req.body.note || '').slice(0, 255); + db.run(`INSERT INTO ivanti_finding_notes ...`); +}); +``` + +**Impact:** Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks. + +**Fix:** Add `requireAuth(db)` to this route. + +--- + +### C-4 — No Brute Force Protection on Login Endpoint + +**File:** `backend/routes/auth.js:10` + +The login endpoint has no rate limiting, attempt counting, or lockout: + +```js +router.post('/login', async (req, res) => { + const { username, password } = req.body; + // Direct DB lookup, unlimited attempts +``` + +**Impact:** An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target. + +**Fix:** Apply `express-rate-limit` to the login route: +```js +const rateLimit = require('express-rate-limit'); +const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }); +router.post('/login', loginLimiter, async (req, res) => { ... }); +``` + +--- + +### C-5 — Default Credentials Displayed in Login UI + +**File:** `frontend/src/components/LoginForm.js:104` + +The login form renders hardcoded credentials in plain text: + +```jsx +

+ Default: admin / + admin123 +

+``` + +**Impact:** Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed. + +**Fix:** Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side. + +--- + +### C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe + +**File:** `frontend/src/components/KnowledgeBaseViewer.js:195` + +The inline document viewer renders uploaded files in an unsandboxed iframe: + +```jsx +