Files
TimeTracker/app/utils/permissions.py
Dries Peeters 944b69a7fc feat: implement full permission enforcement and enhanced UI visibility
BREAKING CHANGE: Permission system now actively enforced across all routes

## Summary
Complete implementation of advanced role-based access control (RBAC) system
with full route protection, UI conditionals, and enhanced management interface.

## Route Protection
- Updated all admin routes to use @admin_or_permission_required decorator
- Replaced inline admin checks with granular permission checks in:
  * Admin routes: user management, settings, backups, telemetry, OIDC
  * Project routes: create, edit, delete, archive, bulk operations
  * Client routes: create, edit, delete, archive, bulk operations
- Maintained backward compatibility with existing @admin_required decorator

## UI Permission Integration
- Added template helpers (has_permission, has_any_permission) to all templates
- Navigation conditionally shows admin/OIDC links based on permissions
- Action buttons (Edit, Delete, Archive) conditional on user permissions
- Project and client pages respect permission requirements
- Create buttons visible only with appropriate permissions

## Enhanced Roles & Permissions UI
- Added statistics dashboard showing:
  * Total roles, system roles, custom roles, assigned users
- Implemented expandable permission details in roles list
  * Click to view all permissions grouped by category
  * Visual checkmarks for assigned permissions
- Enhanced user list with role visibility:
  * Shows all assigned roles as color-coded badges
  * Blue badges for system roles, gray for custom roles
  * Yellow badges for legacy roles with migration prompt
  * Merged legacy role column into unified "Roles & Permissions"
- User count per role now clickable and accurate

## Security Improvements
- Added CSRF tokens to all new permission system forms:
  * Role creation/edit form
  * Role deletion form
  * User role assignment form
- All POST requests now protected against CSRF attacks

## Technical Details
- Fixed SQLAlchemy relationship query issues (AppenderQuery)
- Proper use of .count() for relationship aggregation
- Jinja2 namespace for accumulating counts in templates
- Responsive grid layouts for statistics and permission cards

## Documentation
- Created comprehensive implementation guides
- Added permission enforcement documentation
- Documented UI enhancements and features
- Included CSRF protection review

## Impact
- Permissions are now actively enforced, not just defined
- Admins can easily see who has what access
- Clear visual indicators of permission assignments
- Secure forms with CSRF protection
- Production-ready permission system
2025-10-24 12:49:54 +02:00

178 lines
5.1 KiB
Python

"""Utilities for permission checking and decorators"""
from functools import wraps
from flask import flash, redirect, url_for, abort
from flask_login import current_user
from flask_babel import gettext as _
def permission_required(*permissions, require_all=False):
"""
Decorator to require one or more permissions.
Args:
*permissions: Permission name(s) required
require_all: If True, user must have ALL permissions. If False, user needs ANY permission.
Example:
@permission_required('edit_projects')
def edit_project():
...
@permission_required('view_reports', 'export_reports', require_all=True)
def export_report():
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash(_('Please log in to access this page'), 'error')
return redirect(url_for('auth.login'))
# Check if user has required permissions
if require_all:
has_access = current_user.has_all_permissions(*permissions)
else:
has_access = current_user.has_any_permission(*permissions)
if not has_access:
flash(_('You do not have permission to access this page'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_or_permission_required(*permissions):
"""
Decorator that allows access if user is an admin OR has any of the specified permissions.
This is useful for gradual migration from admin-only to permission-based access.
Example:
@admin_or_permission_required('delete_projects')
def delete_project():
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash(_('Please log in to access this page'), 'error')
return redirect(url_for('auth.login'))
# Allow access if user is admin or has any of the required permissions
if current_user.is_admin or current_user.has_any_permission(*permissions):
return f(*args, **kwargs)
flash(_('You do not have permission to access this page'), 'error')
abort(403)
return decorated_function
return decorator
def check_permission(user, permission_name):
"""
Check if a user has a specific permission.
Args:
user: User object
permission_name: Name of the permission to check
Returns:
bool: True if user has the permission, False otherwise
"""
if not user or not user.is_authenticated:
return False
return user.has_permission(permission_name)
def check_any_permission(user, *permission_names):
"""
Check if a user has any of the specified permissions.
Args:
user: User object
*permission_names: Names of permissions to check
Returns:
bool: True if user has any of the permissions, False otherwise
"""
if not user or not user.is_authenticated:
return False
return user.has_any_permission(*permission_names)
def check_all_permissions(user, *permission_names):
"""
Check if a user has all of the specified permissions.
Args:
user: User object
*permission_names: Names of permissions to check
Returns:
bool: True if user has all of the permissions, False otherwise
"""
if not user or not user.is_authenticated:
return False
return user.has_all_permissions(*permission_names)
def get_user_permissions(user):
"""
Get all permissions for a user.
Args:
user: User object
Returns:
list: List of Permission objects
"""
if not user:
return []
return user.get_all_permissions()
def get_user_permission_names(user):
"""
Get all permission names for a user.
Args:
user: User object
Returns:
list: List of permission name strings
"""
if not user:
return []
permissions = user.get_all_permissions()
return [p.name for p in permissions]
# Template helper functions (register these in app context)
def init_permission_helpers(app):
"""
Initialize permission helper functions for use in templates.
Usage in templates:
{% if has_permission('edit_projects') %}
<button>Edit Project</button>
{% endif %}
"""
@app.context_processor
def inject_permission_helpers():
return {
'has_permission': lambda perm: check_permission(current_user, perm),
'has_any_permission': lambda *perms: check_any_permission(current_user, *perms),
'has_all_permissions': lambda *perms: check_all_permissions(current_user, *perms),
'get_user_permissions': lambda: get_user_permission_names(current_user) if current_user.is_authenticated else [],
}