Files
TimeTracker/app/routes/permissions.py
T
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

275 lines
11 KiB
Python

"""Routes for role and permission management (admin only)"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, limiter
from app.models import Permission, Role, User
from app.routes.admin import admin_required
from app.utils.db import safe_commit
from sqlalchemy.exc import IntegrityError
permissions_bp = Blueprint('permissions', __name__)
@permissions_bp.route('/admin/roles')
@login_required
@admin_required
def list_roles():
"""List all roles"""
# Check if user has permission to view roles
if not current_user.is_admin and not current_user.has_permission('view_permissions'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
roles = Role.query.order_by(Role.name).all()
return render_template('admin/roles/list.html', roles=roles)
@permissions_bp.route('/admin/roles/create', methods=['GET', 'POST'])
@login_required
@admin_required
def create_role():
"""Create a new role"""
# Check if user has permission to manage roles
if not current_user.is_admin and not current_user.has_permission('manage_roles'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('Role name is required'), 'error')
return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all())
# Check if role already exists
if Role.query.filter_by(name=name).first():
flash(_('A role with this name already exists'), 'error')
return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all())
# Create role
role = Role(name=name, description=description, is_system_role=False)
db.session.add(role)
# Assign selected permissions
permission_ids = request.form.getlist('permissions')
for perm_id in permission_ids:
permission = Permission.query.get(int(perm_id))
if permission:
role.add_permission(permission)
if not safe_commit('create_role', {'name': name}):
flash(_('Could not create role due to a database error'), 'error')
return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all())
flash(_('Role created successfully'), 'success')
return redirect(url_for('permissions.list_roles'))
# GET request
all_permissions = Permission.query.order_by(Permission.category, Permission.name).all()
return render_template('admin/roles/form.html', role=None, all_permissions=all_permissions)
@permissions_bp.route('/admin/roles/<int:role_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_role(role_id):
"""Edit an existing role"""
# Check if user has permission to manage roles
if not current_user.is_admin and not current_user.has_permission('manage_roles'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
role = Role.query.get_or_404(role_id)
# Prevent editing system roles
if role.is_system_role:
flash(_('System roles cannot be edited'), 'warning')
return redirect(url_for('permissions.view_role', role_id=role.id))
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('Role name is required'), 'error')
return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all())
# Check if name is taken by another role
existing = Role.query.filter_by(name=name).first()
if existing and existing.id != role.id:
flash(_('A role with this name already exists'), 'error')
return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all())
# Update role
role.name = name
role.description = description
# Update permissions
permission_ids = request.form.getlist('permissions')
# Remove all current permissions
role.permissions = []
# Add selected permissions
for perm_id in permission_ids:
permission = Permission.query.get(int(perm_id))
if permission:
role.add_permission(permission)
if not safe_commit('edit_role', {'role_id': role.id}):
flash(_('Could not update role due to a database error'), 'error')
return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all())
flash(_('Role updated successfully'), 'success')
return redirect(url_for('permissions.view_role', role_id=role.id))
# GET request
all_permissions = Permission.query.order_by(Permission.category, Permission.name).all()
return render_template('admin/roles/form.html', role=role, all_permissions=all_permissions)
@permissions_bp.route('/admin/roles/<int:role_id>')
@login_required
@admin_required
def view_role(role_id):
"""View role details"""
# Check if user has permission to view roles
if not current_user.is_admin and not current_user.has_permission('view_permissions'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
role = Role.query.get_or_404(role_id)
users = role.users.all()
return render_template('admin/roles/view.html', role=role, users=users)
@permissions_bp.route('/admin/roles/<int:role_id>/delete', methods=['POST'])
@login_required
@admin_required
@limiter.limit("10 per minute")
def delete_role(role_id):
"""Delete a role"""
# Check if user has permission to manage roles
if not current_user.is_admin and not current_user.has_permission('manage_roles'):
flash(_('You do not have permission to perform this action'), 'error')
return redirect(url_for('main.dashboard'))
role = Role.query.get_or_404(role_id)
# Prevent deleting system roles
if role.is_system_role:
flash(_('System roles cannot be deleted'), 'error')
return redirect(url_for('permissions.list_roles'))
# Check if role is assigned to any users
if role.users.count() > 0:
flash(_('Cannot delete role that is assigned to users. Please reassign users first.'), 'error')
return redirect(url_for('permissions.view_role', role_id=role.id))
role_name = role.name
db.session.delete(role)
if not safe_commit('delete_role', {'role_id': role.id}):
flash(_('Could not delete role due to a database error'), 'error')
return redirect(url_for('permissions.list_roles'))
flash(_('Role "%(name)s" deleted successfully', name=role_name), 'success')
return redirect(url_for('permissions.list_roles'))
@permissions_bp.route('/admin/permissions')
@login_required
@admin_required
def list_permissions():
"""List all permissions"""
# Check if user has permission to view permissions
if not current_user.is_admin and not current_user.has_permission('view_permissions'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
# Group permissions by category
permissions = Permission.query.order_by(Permission.category, Permission.name).all()
# Organize by category
permissions_by_category = {}
for perm in permissions:
category = perm.category or 'general'
if category not in permissions_by_category:
permissions_by_category[category] = []
permissions_by_category[category].append(perm)
return render_template('admin/permissions/list.html', permissions_by_category=permissions_by_category)
@permissions_bp.route('/admin/users/<int:user_id>/roles', methods=['GET', 'POST'])
@login_required
@admin_required
def manage_user_roles(user_id):
"""Manage roles for a specific user"""
# Check if user has permission to manage user roles
if not current_user.is_admin and not current_user.has_permission('manage_user_roles'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
if request.method == 'POST':
# Get selected role IDs
role_ids = request.form.getlist('roles')
# Clear current roles
user.roles = []
# Assign selected roles
for role_id in role_ids:
role = Role.query.get(int(role_id))
if role:
user.add_role(role)
if not safe_commit('manage_user_roles', {'user_id': user.id}):
flash(_('Could not update user roles due to a database error'), 'error')
return render_template('admin/users/roles.html', user=user, all_roles=Role.query.all())
flash(_('User roles updated successfully'), 'success')
return redirect(url_for('admin.edit_user', user_id=user.id))
# GET request
all_roles = Role.query.order_by(Role.name).all()
return render_template('admin/users/roles.html', user=user, all_roles=all_roles)
@permissions_bp.route('/api/users/<int:user_id>/permissions')
@login_required
def get_user_permissions(user_id):
"""API endpoint to get user's effective permissions"""
# Users can view their own permissions, admins can view any user's permissions
if current_user.id != user_id and not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
user = User.query.get_or_404(user_id)
permissions = user.get_all_permissions()
return jsonify({
'user_id': user.id,
'username': user.username,
'roles': [{'id': r.id, 'name': r.name} for r in user.roles],
'permissions': [{'id': p.id, 'name': p.name, 'description': p.description} for p in permissions]
})
@permissions_bp.route('/api/roles/<int:role_id>/permissions')
@login_required
@admin_required
def get_role_permissions(role_id):
"""API endpoint to get role's permissions"""
role = Role.query.get_or_404(role_id)
return jsonify({
'role_id': role.id,
'name': role.name,
'description': role.description,
'is_system_role': role.is_system_role,
'permissions': [{'id': p.id, 'name': p.name, 'description': p.description, 'category': p.category} for p in role.permissions]
})