fix(rbac): hide vuln write-actions from read-only + scope notification log

- Vulnerabilities list: the per-row Change-Status / Suppress / Mark-False-
  Positive buttons and the assign dropdown were shown to read-only users and
  threw 'Access denied. Required role: editor'. Gate them (editor+); assign
  select disabled for read-only.
- CVE detail page: Dismiss/Reactivate now shown only to editor+ (fetches
  /auth/me for the role).
- Notification log (/notifications): GET /log returned EVERY sent mail —
  recipient emails + which CVE went to whom — to any authenticated user (privacy
  leak). Now admins see all; everyone else sees only notifications addressed to
  them (user_id == own).
This commit is contained in:
2026-07-13 09:41:21 +02:00
parent 4a25a5d1c0
commit 5e85a5cf25
3 changed files with 23 additions and 4 deletions
+10 -1
View File
@@ -54,9 +54,18 @@ async def list_notification_logs(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List notification history"""
"""List notification history.
Privacy: the log holds recipient emails + which CVE/asset went to whom.
Admins see everything (operational overview); everyone else sees only the
notifications addressed to them (user_id == their own id).
"""
from app.models.user import UserRole
query = db.query(NotificationLog).order_by(desc(NotificationLog.sent_at))
if current_user.role != UserRole.ADMIN:
query = query.filter(NotificationLog.user_id == current_user.id)
if notification_type:
try:
nt = NotificationType(notification_type)
+7 -2
View File
@@ -313,6 +313,11 @@ export default function VulnerabilityDetailPage() {
const [vuln, setVuln] = useState<VulnDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userRole, setUserRole] = useState('');
useEffect(() => {
api.get('/auth/me').then(r => setUserRole(r.data?.role || '')).catch(() => { });
}, []);
const canEdit = userRole === 'admin' || userRole === 'editor';
// Audit / change history for this vuln — populated separately so
// a slow audit query doesn't block the vuln detail render.
type AuditEntry = {
@@ -470,7 +475,7 @@ export default function VulnerabilityDetailPage() {
<span className={`px-4 py-2 rounded-lg text-sm font-medium ${statusColors[vuln.status] || 'bg-gray-100'}`}>
{vuln.status.replace('_', ' ')}
</span>
{vuln.status === 'false_positive' ? (
{canEdit && (vuln.status === 'false_positive' ? (
<button
onClick={handleReactivate}
disabled={dismissBusy}
@@ -488,7 +493,7 @@ export default function VulnerabilityDetailPage() {
>
{dismissBusy ? '…' : 'Dismiss'}
</button>
)}
))}
</div>
</div>
</div>
+6 -1
View File
@@ -1794,7 +1794,8 @@ function VulnerabilitiesContent() {
<select
value={vuln.assigned_group_id ? `group_${vuln.assigned_group_id}` : (vuln.assigned_user_id ? String(vuln.assigned_user_id) : "")}
onChange={(e) => handleAssign(vuln.id, e.target.value)}
className="block w-full rounded-md border-0 py-1.5 pl-2 pr-7 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-xs sm:leading-6 bg-transparent"
disabled={currentUser?.role === 'readonly'}
className="block w-full rounded-md border-0 py-1.5 pl-2 pr-7 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-xs sm:leading-6 bg-transparent disabled:cursor-not-allowed"
style={{ maxWidth: '120px' }}
>
<option value="">Unassigned</option>
@@ -1815,6 +1816,7 @@ function VulnerabilitiesContent() {
className={`sticky right-0 z-10 px-4 py-3 whitespace-nowrap text-right shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] border-l border-gray-100 ${selectedIds.includes(vuln.id) ? 'bg-blue-50' : 'bg-white group-hover:bg-blue-50/30'}`}
>
<div className="flex justify-end gap-1">
{currentUser?.role !== 'readonly' ? (<>
<button
onClick={() => handleOpenStatusModal(vuln)}
className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-truevuln-blue"
@@ -1836,6 +1838,9 @@ function VulnerabilitiesContent() {
>
<NoSymbolIcon className="h-4 w-4" />
</button>
</>) : (
<span className="text-gray-300 text-xs"></span>
)}
</div>
</td>
</tr>