mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-07 13:00:22 -05:00
944b69a7fc
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
275 lines
11 KiB
Python
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]
|
|
})
|
|
|