Files
TimeTracker/app/models/permission.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

109 lines
4.3 KiB
Python

"""Permission model for granular access control"""
from datetime import datetime
from app import db
class Permission(db.Model):
"""Permission model - represents a single permission in the system"""
__tablename__ = 'permissions'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
description = db.Column(db.String(255), nullable=True)
category = db.Column(db.String(50), nullable=False, index=True) # e.g., 'time_entries', 'projects', 'users', 'reports', 'system'
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
def __init__(self, name, description=None, category='general'):
self.name = name
self.description = description
self.category = category
def __repr__(self):
return f'<Permission {self.name}>'
def to_dict(self):
"""Convert permission to dictionary"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'category': self.category,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# Association table for many-to-many relationship between roles and permissions
role_permissions = db.Table('role_permissions',
db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
db.Column('permission_id', db.Integer, db.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True),
db.Column('created_at', db.DateTime, default=datetime.utcnow, nullable=False)
)
class Role(db.Model):
"""Role model - bundles permissions together"""
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False, index=True)
description = db.Column(db.String(255), nullable=True)
is_system_role = db.Column(db.Boolean, default=False, nullable=False) # System roles cannot be deleted
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
permissions = db.relationship('Permission', secondary=role_permissions, lazy='joined',
backref=db.backref('roles', lazy='dynamic'))
def __init__(self, name, description=None, is_system_role=False):
self.name = name
self.description = description
self.is_system_role = is_system_role
def __repr__(self):
return f'<Role {self.name}>'
def has_permission(self, permission_name):
"""Check if role has a specific permission"""
return any(p.name == permission_name for p in self.permissions)
def add_permission(self, permission):
"""Add a permission to this role"""
if not self.has_permission(permission.name):
self.permissions.append(permission)
def remove_permission(self, permission):
"""Remove a permission from this role"""
if self.has_permission(permission.name):
self.permissions.remove(permission)
def get_permission_names(self):
"""Get list of permission names for this role"""
return [p.name for p in self.permissions]
def to_dict(self, include_permissions=False):
"""Convert role to dictionary"""
data = {
'id': self.id,
'name': self.name,
'description': self.description,
'is_system_role': self.is_system_role,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
if include_permissions:
data['permissions'] = [p.to_dict() for p in self.permissions]
data['permission_count'] = len(self.permissions)
return data
# Association table for many-to-many relationship between users and roles
user_roles = db.Table('user_roles',
db.Column('user_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
db.Column('assigned_at', db.DateTime, default=datetime.utcnow, nullable=False)
)