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..ada4a1b --- /dev/null +++ b/backend/migrations/add_user_groups.js @@ -0,0 +1,146 @@ +// 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'); + + // 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(); + } + ); + } + ); + } + ); + } + ); + } + ); + } + ); + } + ); + } + ); + }); + }); + }); +} + +// 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..8474a8e 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); @@ -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 }); @@ -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; @@ -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 }); @@ -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', + entityType: 'archer_ticket', + entityId: String(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..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(); @@ -110,7 +111,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 +121,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 +184,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 +211,7 @@ function createAuthRouter(db, logAudit) { id: session.user_id, username: session.username, email: session.email, - role: session.role + group: session.user_group } }); } catch (err) { @@ -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 640f0c9..41a6432 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') { @@ -520,7 +520,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) { // 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 edf0d1d..10a7029 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; @@ -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)); @@ -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; @@ -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 7abde51..9f29900 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) { @@ -39,8 +39,20 @@ function createKnowledgeBaseRouter(db, upload) { return ALLOWED_EXTENSIONS.has(ext); } - // POST /api/knowledge-base/upload - Upload new document - router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => { + /** + * 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) { console.error('[KB Upload] Multer error:', err); @@ -132,16 +144,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, @@ -161,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 @@ -183,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; @@ -211,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; @@ -232,16 +268,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'; @@ -263,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; @@ -284,16 +328,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}"`); @@ -301,11 +344,22 @@ function createKnowledgeBaseRouter(db, upload) { }); }); - // DELETE /api/knowledge-base/:id - Delete article - router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => { + /** + * 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; - 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 +371,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 +389,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/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..a265ac2 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,71 @@ 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' }); + } + + // 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); @@ -742,7 +945,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re }); }); }); - }); + } }); // ========== DOCUMENT ENDPOINTS ========== @@ -771,7 +974,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 +1082,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 +1184,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 +1210,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 +1238,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 +1303,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 +1315,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/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 +