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

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