feat: implement group-based access control (Admin, Standard_User, Leadership, Read_Only)

- Add user_group migration and created_by column migration
- Replace requireRole middleware with requireGroup
- Update all backend routes to use group-based authorization
- Add Standard_User conditional delete with ownership, state, and compliance checks
- Add cascade impact check for CVE deletes
- Update AuthContext with group-based permission helpers
- Update all frontend components for group-based rendering
- Update UserManagement UI with group dropdown, confirmation dialogs, self-demotion prevention
This commit is contained in:
jramos
2026-04-06 16:18:07 -06:00
parent 1ef57b0504
commit 73fd747576
19 changed files with 1171 additions and 149 deletions

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ function requireAuth(db) {
try {
const session = await new Promise((resolve, reject) => {
db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
@@ -37,7 +37,8 @@ function requireAuth(db) {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
role: session.role,
group: session.user_group
};
next();
@@ -48,18 +49,18 @@ function requireAuth(db) {
};
}
// Require specific role(s)
function requireRole(...allowedRoles) {
// Require specific group(s)
function requireGroup(...allowedGroups) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
if (!allowedGroups.includes(req.user.group)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: allowedRoles,
current: req.user.role
required: allowedGroups,
current: req.user.group
});
}
@@ -67,4 +68,4 @@ function requireRole(...allowedRoles) {
};
}
module.exports = { requireAuth, requireRole };
module.exports = { requireAuth, requireGroup };

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// routes/archerTickets.js
const express = require('express');
const { requireAuth, requireRole } = require('../middleware/auth');
const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
// Validation helpers
@@ -48,7 +48,7 @@ function createArcherTicketsRouter(db) {
});
// Create Archer ticket
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
// Validation
@@ -74,9 +74,9 @@ function createArcherTicketsRouter(db) {
const validatedStatus = status || 'Draft';
db.run(
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
VALUES (?, ?, ?, ?, ?)`,
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
function(err) {
if (err) {
console.error('Error creating Archer ticket:', err);
@@ -104,7 +104,7 @@ function createArcherTicketsRouter(db) {
});
// Update Archer ticket
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { exc_number, archer_url, status } = req.body;
@@ -184,19 +184,8 @@ function createArcherTicketsRouter(db) {
});
});
// Delete Archer ticket
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { id } = req.params;
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) {
return res.status(404).json({ error: 'Archer ticket not found.' });
}
// 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);
@@ -214,6 +203,60 @@ function createArcherTicketsRouter(db) {
res.json({ message: 'Archer ticket deleted successfully' });
});
}
// Delete Archer ticket
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) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) {
return res.status(404).json({ error: 'Archer ticket not found.' });
}
// 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);
}
);
});
});

View File

@@ -1,11 +1,11 @@
// Audit Log Routes (Admin only)
const express = require('express');
function createAuditLogRouter(db, requireAuth, requireRole) {
function createAuditLogRouter(db, requireAuth, requireGroup) {
const router = express.Router();
// All routes require admin role
router.use(requireAuth(db), requireRole('admin'));
// All routes require Admin group
router.use(requireAuth(db), requireGroup('Admin'));
// Get paginated audit logs with filters
router.get('/', async (req, res) => {

View File

@@ -110,7 +110,7 @@ function createAuthRouter(db, logAudit) {
action: 'login',
entityType: 'auth',
entityId: null,
details: { role: user.role },
details: { group: user.user_group },
ipAddress: req.ip
});
@@ -120,7 +120,7 @@ function createAuthRouter(db, logAudit) {
id: user.id,
username: user.username,
email: user.email,
role: user.role
group: user.user_group
}
});
} catch (err) {
@@ -183,7 +183,7 @@ function createAuthRouter(db, logAudit) {
try {
const session = await new Promise((resolve, reject) => {
db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
@@ -210,7 +210,7 @@ function createAuthRouter(db, logAudit) {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
group: session.user_group
}
});
} catch (err) {

View File

@@ -213,7 +213,7 @@ function groupByHostname(rows, noteHostnames) {
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createComplianceRouter(db, upload, requireAuth, requireRole) {
function createComplianceRouter(db, upload, requireAuth, requireGroup) {
const router = express.Router();
// Idempotent column additions — errors mean column already exists, which is fine
@@ -228,7 +228,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
// Returns diff counts + tempFile path for the commit step.
// -----------------------------------------------------------------------
router.post('/preview', requireRole('editor', 'admin'), (req, res) => {
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
upload.single('file')(req, res, async (uploadErr) => {
if (uploadErr) {
return res.status(400).json({ error: uploadErr.message });
@@ -291,7 +291,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
// Commit a previewed upload to the DB.
// Body: { tempFile, filename, report_date }
// -----------------------------------------------------------------------
router.post('/commit', requireRole('editor', 'admin'), async (req, res) => {
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { tempFile, filename, report_date } = req.body;
if (!tempFile || typeof tempFile !== 'string') {

View File

@@ -4,7 +4,7 @@
const express = require('express');
const https = require('https');
const { requireRole } = require('../middleware/auth');
const { requireGroup } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -899,7 +899,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
// PUT /:findingId/override — save or clear a field override (editor/admin only)
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params;
const { field, value } = req.body;

View File

@@ -1,7 +1,7 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const { requireAuth, requireRole } = require('../middleware/auth');
const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
function createKnowledgeBaseRouter(db, upload) {
@@ -40,7 +40,7 @@ function createKnowledgeBaseRouter(db, upload) {
}
// POST /api/knowledge-base/upload - Upload new document
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res, next) => {
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
console.error('[KB Upload] Multer error:', err);
@@ -302,7 +302,7 @@ function createKnowledgeBaseRouter(db, upload) {
});
// DELETE /api/knowledge-base/:id - Delete article
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), (req, res) => {
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?';

View File

@@ -2,18 +2,18 @@
const express = require('express');
const bcrypt = require('bcryptjs');
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
const router = express.Router();
// All routes require admin role
router.use(requireAuth(db), requireRole('admin'));
// All routes require Admin group
router.use(requireAuth(db), requireGroup('Admin'));
// Get all users
router.get('/', async (req, res) => {
try {
const users = await new Promise((resolve, reject) => {
db.all(
`SELECT id, username, email, role, is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
FROM users ORDER BY created_at DESC`,
(err, rows) => {
if (err) reject(err);
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
try {
const user = await new Promise((resolve, reject) => {
db.get(
`SELECT id, username, email, role, is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
FROM users WHERE id = ?`,
[req.params.id],
(err, row) => {
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
// Create new user
router.post('/', async (req, res) => {
const { username, email, password, role } = req.body;
const { username, email, password, group } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
const userGroup = group || 'Read_Only';
if (!VALID_GROUPS.includes(userGroup)) {
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
}
try {
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const result = await new Promise((resolve, reject) => {
db.run(
`INSERT INTO users (username, email, password_hash, role)
`INSERT INTO users (username, email, password_hash, user_group)
VALUES (?, ?, ?, ?)`,
[username, email, passwordHash, role || 'viewer'],
[username, email, passwordHash, userGroup],
function(err) {
if (err) reject(err);
else resolve({ id: this.lastID });
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
action: 'user_create',
entityType: 'user',
entityId: String(result.id),
details: { created_username: username, role: role || 'viewer' },
details: { created_username: username, group: userGroup },
ipAddress: req.ip
});
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
id: result.id,
username,
email,
role: role || 'viewer'
group: userGroup
}
});
} catch (err) {
@@ -111,12 +114,18 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
// Update user
router.patch('/:id', async (req, res) => {
const { username, email, password, role, is_active } = req.body;
const { username, email, password, group, is_active } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const userId = req.params.id;
// Prevent self-demotion from admin
if (userId == req.user.id && role && role !== 'admin') {
return res.status(400).json({ error: 'Cannot remove your own admin role' });
// Validate group if provided
if (group && !VALID_GROUPS.includes(group)) {
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
}
// Prevent admin self-demotion
if (userId == req.user.id && group && group !== 'Admin') {
return res.status(400).json({ error: 'Cannot remove your own admin group' });
}
// Prevent self-deactivation
@@ -125,6 +134,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
}
try {
// Fetch current user record before update (needed for group change audit)
const currentUser = await new Promise((resolve, reject) => {
db.get(
'SELECT user_group FROM users WHERE id = ?',
[userId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!currentUser) {
return res.status(404).json({ error: 'User not found' });
}
const updates = [];
const values = [];
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
updates.push('password_hash = ?');
values.push(passwordHash);
}
if (role) {
if (!['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
updates.push('role = ?');
values.push(role);
if (group) {
updates.push('user_group = ?');
values.push(group);
}
if (typeof is_active === 'boolean') {
updates.push('is_active = ?');
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const updatedFields = {};
if (username) updatedFields.username = username;
if (email) updatedFields.email = email;
if (role) updatedFields.role = role;
if (group) updatedFields.group = group;
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
if (password) updatedFields.password_changed = true;
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
ipAddress: req.ip
});
// Log specific audit entry for group changes
if (group && group !== currentUser.user_group) {
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_group_change',
entityType: 'user',
entityId: String(userId),
details: {
previous_group: currentUser.user_group,
new_group: group
},
ipAddress: req.ip
});
}
// If user was deactivated, delete their sessions
if (is_active === false) {
await new Promise((resolve) => {

View File

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

View File

@@ -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() {
<span className="text-gray-500 mx-2"></span>
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
</p>
{selectedDocuments.length > 0 && (
{selectedDocuments.length > 0 && canExport() && (
<button
onClick={exportSelectedDocuments}
className="intel-button intel-button-primary flex items-center gap-2"
@@ -1833,7 +1833,7 @@ export default function App() {
<span>Published: {vendorEntries[0].published_date}</span>
<span className="text-intel-accent"></span>
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
{canWrite() && vendorEntries.length >= 2 && (
{isAdmin() && vendorEntries.length >= 2 && (
<button
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
@@ -1894,7 +1894,7 @@ export default function App() {
<Edit2 className="w-4 h-4" />
</button>
)}
{canWrite() && (
{canDelete(cve) && (
<button
onClick={() => handleDeleteCVEEntry(cve)}
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
@@ -2026,9 +2026,11 @@ export default function App() {
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-4 h-4" />
</button>
{canDelete(ticket) && (
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
@@ -2152,9 +2154,11 @@ export default function App() {
<button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-3 h-3" />
</button>
{canDelete(ticket) && (
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
@@ -2220,14 +2224,16 @@ export default function App() {
>
<Filter className="w-3 h-3" />
</button>
{canWrite() && (<>
{canWrite() && (
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" />
</button>
)}
{canDelete(ticket) && (
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</>)}
)}
</div>
</div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
@@ -2256,6 +2262,7 @@ export default function App() {
<Activity className="w-5 h-5" />
Ivanti Workflows
</h2>
{canWrite() && (
<button
onClick={syncIvantiWorkflows}
disabled={ivantiSyncing || ivantiLoading}
@@ -2265,6 +2272,7 @@ export default function App() {
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
{ivantiSyncing ? 'Syncing…' : 'Sync'}
</button>
)}
</div>
{/* Last synced line */}

View File

@@ -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 })
</button>
);
})}
{/* Admin panel link — visible only to Admin group */}
{isAdmin() && (() => {
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
const active = currentPage === id;
return (
<button
key={id}
onClick={() => { onNavigate(id); onClose(); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.875rem',
padding: '0.75rem 0.875rem',
borderRadius: '0.5rem',
border: active ? `1px solid ${color}50` : '1px solid transparent',
background: active ? `${color}18` : 'transparent',
cursor: 'pointer', textAlign: 'left', width: '100%',
marginTop: '0.5rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
paddingTop: '1rem',
transition: 'background 0.15s, border-color 0.15s'
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{
width: '36px', height: '36px', flexShrink: 0,
borderRadius: '0.375rem',
background: `${color}18`,
border: `1px solid ${color}40`,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Icon style={{ width: '17px', height: '17px', color }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
color: active ? color : '#CBD5E1',
textTransform: 'uppercase', letterSpacing: '0.06em'
}}>
{label}
</div>
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
{description}
</div>
</div>
{active && (
<div style={{
width: '6px', height: '6px', borderRadius: '50%',
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
}} />
)}
</button>
);
})()}
</nav>
{/* Footer */}

View File

@@ -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 }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role *
Group *
</label>
<div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
value={formData.group}
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
disabled={isGroupDropdownDisabled(editingUser)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
>
<option value="viewer">Viewer (read-only)</option>
<option value="editor">Editor (can add CVEs, upload docs)</option>
<option value="admin">Admin (full access)</option>
{VALID_GROUPS.map((g) => (
<option key={g} value={g}>{GROUP_LABELS[g]}</option>
))}
</select>
{isGroupDropdownDisabled(editingUser) && (
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
)}
</div>
</div>
</div>
@@ -308,7 +342,7 @@ export default function UserManagement({ onClose }) {
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Role</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
@@ -324,8 +358,17 @@ export default function UserManagement({ onClose }) {
</div>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
<span
style={{
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
display: 'inline-block'
}}
>
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
</span>
</td>
<td className="py-3 px-4">

View File

@@ -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 }) {
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
<p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
</div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
@@ -72,8 +81,8 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
{formatGroupName(user.group)}
</span>
</div>

View File

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