mirror of
https://github.com/MrRobotjs/MUM.git
synced 2025-12-30 05:09:36 -06:00
Implement Discord-style RBAC with hierarchical roles
Replaces the legacy role system with a Discord-style Role-Based Access Control (RBAC) model featuring hierarchical admin roles, granular permissions, and many-to-many relationships between users, roles, and permissions. Adds new models and migration for admin roles, permissions, visual user roles, and their assignments. Updates User and related models to support new RBAC methods and maintains legacy compatibility. Removes obsolete migrations and updates templates for new settings route.
This commit is contained in:
157
DISCORD_RBAC_IMPLEMENTATION.md
Normal file
157
DISCORD_RBAC_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 🎯 Discord-Style RBAC Implementation
|
||||
|
||||
## Overview
|
||||
We've successfully implemented a Discord-style Role-Based Access Control (RBAC) system with hierarchical permissions, replacing the simple role system with a sophisticated many-to-many relationship structure.
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
### Core Tables
|
||||
|
||||
1. **`admin_permissions`** - Individual permissions
|
||||
- `id` (UUID) - Primary key
|
||||
- `name` (String) - Permission name (e.g., 'manage_users')
|
||||
- `description` (Text) - Human-readable description
|
||||
|
||||
2. **`admins_roles`** - Admin roles with hierarchy
|
||||
- `id` (UUID) - Primary key
|
||||
- `name` (String) - Role name (e.g., 'Master', 'Admin', 'Moderator')
|
||||
- `description` (String) - Role description
|
||||
- `position` (Integer) - Hierarchy value (higher = more powerful)
|
||||
- `color` (String) - Hex color for UI display
|
||||
- `icon` (String) - FontAwesome icon class
|
||||
|
||||
3. **`users_roles`** - Visual user roles (labels)
|
||||
- `id` (UUID) - Primary key
|
||||
- `name` (String) - Role name (e.g., 'Friend', 'Family', 'VIP')
|
||||
- `description` (Text) - Role description
|
||||
- `color` (String) - Hex color for UI display
|
||||
- `icon` (String) - FontAwesome icon class
|
||||
|
||||
### Junction Tables
|
||||
|
||||
4. **`admin_role_permissions`** - Links roles to permissions
|
||||
- `role_id` (UUID) → `admins_roles.id`
|
||||
- `permission_id` (UUID) → `admin_permissions.id`
|
||||
|
||||
5. **`admin_user_roles_assignments`** - Links users to admin roles
|
||||
- `user_id` (UUID) → `users.uuid`
|
||||
- `role_id` (UUID) → `admins_roles.id`
|
||||
|
||||
6. **`users_roles_assignments`** - Links users to visual roles
|
||||
- `user_id` (UUID) → `users.uuid`
|
||||
- `visual_role_id` (UUID) → `users_roles.id`
|
||||
|
||||
## 🔑 Key Features
|
||||
|
||||
### Discord-Style Hierarchy
|
||||
- **Position-based hierarchy**: Higher `position` values = more powerful roles
|
||||
- **Role management restrictions**: Users can only manage roles with lower positions
|
||||
- **Owner supremacy**: Owners bypass all hierarchy restrictions
|
||||
|
||||
### Many-to-Many Relationships
|
||||
- **Multiple admin roles per user**: Users can have several admin roles simultaneously
|
||||
- **Multiple visual roles per user**: Users can have multiple cosmetic labels
|
||||
- **Permission union**: User's effective permissions = union of all their roles' permissions
|
||||
|
||||
### Permission System
|
||||
- **Granular permissions**: Individual permissions like 'manage_users', 'manage_roles'
|
||||
- **Role-based assignment**: Permissions are assigned to roles, not directly to users
|
||||
- **Hierarchical management**: Users can only manage roles below their highest position
|
||||
|
||||
## 🏗️ Model Classes
|
||||
|
||||
### `AdminPermission`
|
||||
```python
|
||||
- Individual permissions that can be assigned to roles
|
||||
- Methods: Standard CRUD operations
|
||||
```
|
||||
|
||||
### `AdminRole`
|
||||
```python
|
||||
- Admin roles with hierarchy and permissions
|
||||
- Methods:
|
||||
- has_permission(permission_name) - Check if role has specific permission
|
||||
- can_manage_role(other_role) - Check hierarchy permissions
|
||||
- get_users_with_role(role_id) - Get all users with this role
|
||||
```
|
||||
|
||||
### `UserRole`
|
||||
```python
|
||||
- Visual roles for user organization/labeling
|
||||
- Methods:
|
||||
- get_users_with_role(role_id) - Get all users with this visual role
|
||||
```
|
||||
|
||||
### `User` (Enhanced)
|
||||
```python
|
||||
- Enhanced with RBAC methods
|
||||
- Admin Role Methods:
|
||||
- get_admin_roles() - Get all admin roles
|
||||
- has_admin_role(role_id) - Check specific admin role
|
||||
- add_admin_role(role) - Add admin role
|
||||
- remove_admin_role(role) - Remove admin role
|
||||
- set_admin_roles(roles) - Replace all admin roles
|
||||
- clear_admin_roles() - Remove all admin roles
|
||||
|
||||
- Visual Role Methods:
|
||||
- get_visual_roles() - Get all visual roles
|
||||
- has_visual_role(role_id) - Check specific visual role
|
||||
- add_visual_role(role) - Add visual role
|
||||
- remove_visual_role(role) - Remove visual role
|
||||
|
||||
- Permission Methods:
|
||||
- has_permission(permission_name) - Check permission across all roles
|
||||
- get_all_permissions() - Get unique permissions from all roles
|
||||
- get_highest_role_position() - Get highest hierarchy position
|
||||
- can_manage_role(target_role) - Check if can manage specific role
|
||||
```
|
||||
|
||||
## 📋 Default Permissions
|
||||
|
||||
The system includes these default admin permissions:
|
||||
- `manage_users` - Create, edit, and delete users
|
||||
- `manage_roles` - Create, edit, and delete admin roles
|
||||
- `manage_permissions` - Assign permissions to roles
|
||||
- `manage_settings` - Access and modify application settings
|
||||
- `view_logs` - View application logs and audit trails
|
||||
- `manage_invites` - Create and manage user invitations
|
||||
- `manage_servers` - Configure media servers
|
||||
- `manage_plugins` - Install and configure plugins
|
||||
|
||||
## 🔄 Migration Status
|
||||
|
||||
✅ **Migration created**: `implement_discord_style_rbac.py`
|
||||
✅ **Models updated**: All RBAC models implemented
|
||||
✅ **Relationships established**: Many-to-many tables created
|
||||
✅ **Legacy compatibility**: Old methods maintained for backward compatibility
|
||||
✅ **Default data**: Default permissions will be inserted on migration
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Run the migration**: `flask db upgrade`
|
||||
2. **Create default roles**: Set up Master, Admin, Moderator roles with appropriate positions
|
||||
3. **Update templates**: Modify admin interface to use new role system
|
||||
4. **Update routes**: Ensure all permission checks use new RBAC methods
|
||||
5. **Test hierarchy**: Verify role management restrictions work correctly
|
||||
|
||||
## 💡 Usage Examples
|
||||
|
||||
```python
|
||||
# Check permission
|
||||
if current_user.has_permission('manage_users'):
|
||||
# User can manage users
|
||||
|
||||
# Add admin role
|
||||
master_role = AdminRole.query.filter_by(name='Master').first()
|
||||
user.add_admin_role(master_role)
|
||||
|
||||
# Check role hierarchy
|
||||
if current_user.can_manage_role(target_role):
|
||||
# User can manage this role
|
||||
|
||||
# Add visual role
|
||||
friend_role = UserRole.query.filter_by(name='Friend').first()
|
||||
user.add_visual_role(friend_role)
|
||||
```
|
||||
|
||||
This implementation provides a robust, Discord-style RBAC system that's both powerful and flexible for managing complex permission scenarios.
|
||||
201
app/models.py
201
app/models.py
@@ -10,15 +10,12 @@ from sqlalchemy.types import TypeDecorator, TEXT
|
||||
from sqlalchemy.ext.mutable import MutableDict, MutableList
|
||||
from app.extensions import db, JSONEncodedDict
|
||||
import secrets
|
||||
import uuid
|
||||
from flask import current_app
|
||||
from sqlalchemy import Table, Column, Integer, ForeignKey
|
||||
from app.models_media_services import MediaServer
|
||||
|
||||
# Many-to-many relationship table for users and roles
|
||||
app_user_roles = db.Table('app_user_roles',
|
||||
db.Column('app_user_id', db.Integer, db.ForeignKey('users.id'), primary_key=True),
|
||||
db.Column('role_id', db.Integer, db.ForeignKey('roles.id'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
# Many-to-many relationship table for invites and servers
|
||||
invite_servers = db.Table('invite_servers',
|
||||
@@ -61,18 +58,83 @@ class EventType(enum.Enum): # ... (as before, will add bot-specific events later
|
||||
DISCORD_BOT_GUILD_MEMBER_CHECK_FAIL = "DISCORD_BOT_GUILD_MEMBER_CHECK_FAIL" # Failed guild check on invite page
|
||||
# Add Bot Specific Event Types Later, e.g., BOT_USER_PURGED, BOT_INVITE_SENT
|
||||
|
||||
class Role(db.Model):
|
||||
__tablename__ = 'roles'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), unique=True, nullable=False)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
# Permissions for this role will be stored as a simple JSON list of strings.
|
||||
permissions = db.Column(MutableList.as_mutable(JSONEncodedDict), nullable=True, default=list)
|
||||
color = db.Column(db.String(7), nullable=True, default='#808080') # Default to a neutral gray
|
||||
icon = db.Column(db.String(100), nullable=True)
|
||||
# Junction tables for Discord-style RBAC
|
||||
admin_role_permissions = db.Table('admin_role_permissions',
|
||||
db.Column('role_id', db.String(36), db.ForeignKey('admins_roles.id', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('permission_id', db.String(36), db.ForeignKey('admin_permissions.id', ondelete='CASCADE'), primary_key=True)
|
||||
)
|
||||
|
||||
admin_user_roles_assignments = db.Table('admin_user_roles_assignments',
|
||||
db.Column('user_id', db.String(36), db.ForeignKey('users.uuid', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('role_id', db.String(36), db.ForeignKey('admins_roles.id', ondelete='CASCADE'), primary_key=True)
|
||||
)
|
||||
|
||||
users_roles_assignments = db.Table('users_roles_assignments',
|
||||
db.Column('user_id', db.String(36), db.ForeignKey('users.uuid', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('visual_role_id', db.String(36), db.ForeignKey('users_roles.id', ondelete='CASCADE'), primary_key=True)
|
||||
)
|
||||
|
||||
class AdminPermission(db.Model):
|
||||
"""Individual permissions that can be assigned to admin roles"""
|
||||
__tablename__ = 'admin_permissions'
|
||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Role {self.name}>'
|
||||
return f'<AdminPermission {self.name}>'
|
||||
|
||||
class AdminRole(db.Model):
|
||||
"""Admin roles with Discord-style hierarchy and permissions"""
|
||||
__tablename__ = 'admins_roles'
|
||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = db.Column(db.String(80), unique=True, nullable=False)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
position = db.Column(db.Integer, nullable=False, default=0) # Hierarchy position (higher = more powerful)
|
||||
color = db.Column(db.String(7), nullable=True, default='#808080')
|
||||
icon = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Many-to-many relationship with permissions
|
||||
permissions = db.relationship('AdminPermission', secondary=admin_role_permissions,
|
||||
lazy='subquery', backref=db.backref('roles', lazy=True))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AdminRole {self.name} (pos: {self.position})>'
|
||||
|
||||
def has_permission(self, permission_name):
|
||||
"""Check if this role has a specific permission"""
|
||||
return any(perm.name == permission_name for perm in self.permissions)
|
||||
|
||||
def can_manage_role(self, other_role):
|
||||
"""Check if this role can manage another role (hierarchy check)"""
|
||||
return self.position > other_role.position
|
||||
|
||||
@classmethod
|
||||
def get_users_with_role(cls, role_id):
|
||||
"""Get all users that have this admin role assigned"""
|
||||
return User.query.join(admin_user_roles_assignments).filter(admin_user_roles_assignments.c.role_id == role_id).all()
|
||||
|
||||
# Legacy alias for backward compatibility
|
||||
Role = AdminRole
|
||||
|
||||
class UserRole(db.Model):
|
||||
"""Visual user roles are labels for admins to assign to users (like 'Friend', 'Family', etc.)"""
|
||||
__tablename__ = 'users_roles'
|
||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
color = db.Column(db.String(7), nullable=True, default='#808080')
|
||||
icon = db.Column(db.String(100), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<UserRole {self.name}>'
|
||||
|
||||
@classmethod
|
||||
def get_users_with_role(cls, role_id):
|
||||
"""Get all users that have this visual role assigned"""
|
||||
return User.query.join(users_roles_assignments).filter(users_roles_assignments.c.visual_role_id == role_id).all()
|
||||
|
||||
class UserType(enum.Enum):
|
||||
"""User type enumeration for the unified User model"""
|
||||
@@ -169,12 +231,20 @@ class User(db.Model, UserMixin):
|
||||
plex_username = db.Column(db.String(255), nullable=True)
|
||||
plex_thumb = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# Legacy columns (kept for backward compatibility but not used in new RBAC system)
|
||||
user_role_ids = db.Column(MutableList.as_mutable(JSONEncodedDict), default=list)
|
||||
admin_roles_id = db.Column(db.Integer, nullable=True) # Legacy column, not used
|
||||
|
||||
# Relationships
|
||||
linked_parent = db.relationship('User', remote_side=[uuid], backref='linked_children')
|
||||
roles = db.relationship('Role', secondary='app_user_roles', lazy='subquery',
|
||||
backref=db.backref('users', lazy=True))
|
||||
server = db.relationship('MediaServer', foreign_keys=[server_id], back_populates='users')
|
||||
|
||||
# Discord-style RBAC relationships
|
||||
admin_roles = db.relationship('AdminRole', secondary=admin_user_roles_assignments, lazy='subquery',
|
||||
backref=db.backref('users', lazy=True))
|
||||
visual_roles = db.relationship('UserRole', secondary=users_roles_assignments, lazy='subquery',
|
||||
backref=db.backref('users', lazy=True))
|
||||
|
||||
# Template compatibility property - returns linked service users for templates
|
||||
@property
|
||||
def linked_service_users(self):
|
||||
@@ -191,6 +261,65 @@ class User(db.Model, UserMixin):
|
||||
"""Get service users for template compatibility"""
|
||||
return self.linked_service_users
|
||||
|
||||
# Visual Role Methods (new many-to-many system)
|
||||
def get_visual_roles(self):
|
||||
"""Get all visual roles assigned to this user"""
|
||||
return self.visual_roles
|
||||
|
||||
def has_visual_role(self, role_id):
|
||||
"""Check if user has a specific visual role"""
|
||||
return any(role.id == role_id for role in self.visual_roles)
|
||||
|
||||
def add_visual_role(self, role):
|
||||
"""Add a visual role to this user"""
|
||||
if role not in self.visual_roles:
|
||||
self.visual_roles.append(role)
|
||||
|
||||
def remove_visual_role(self, role):
|
||||
"""Remove a visual role from this user"""
|
||||
if role in self.visual_roles:
|
||||
self.visual_roles.remove(role)
|
||||
|
||||
# Admin Role Methods (new many-to-many system)
|
||||
def get_admin_roles(self):
|
||||
"""Get all admin roles assigned to this user"""
|
||||
return self.admin_roles
|
||||
|
||||
def has_admin_role(self, role_id):
|
||||
"""Check if user has a specific admin role"""
|
||||
return any(role.id == role_id for role in self.admin_roles)
|
||||
|
||||
def add_admin_role(self, role):
|
||||
"""Add an admin role to this user"""
|
||||
if role not in self.admin_roles:
|
||||
self.admin_roles.append(role)
|
||||
|
||||
def remove_admin_role(self, role):
|
||||
"""Remove an admin role from this user"""
|
||||
if role in self.admin_roles:
|
||||
self.admin_roles.remove(role)
|
||||
|
||||
def set_admin_roles(self, roles):
|
||||
"""Set the admin roles for this user (replaces all existing)"""
|
||||
self.admin_roles = roles
|
||||
|
||||
def clear_admin_roles(self):
|
||||
"""Remove all admin roles from this user"""
|
||||
self.admin_roles = []
|
||||
|
||||
# Legacy methods for backward compatibility
|
||||
def get_user_roles(self):
|
||||
"""Legacy method - returns visual roles"""
|
||||
return self.get_visual_roles()
|
||||
|
||||
def has_user_role(self, role_id):
|
||||
"""Legacy method - checks visual roles"""
|
||||
return self.has_visual_role(role_id)
|
||||
|
||||
def get_admin_role(self):
|
||||
"""Legacy method - returns first admin role if any"""
|
||||
return self.admin_roles[0] if self.admin_roles else None
|
||||
|
||||
def __repr__(self):
|
||||
if self.userType == UserType.OWNER:
|
||||
return f'<User(OWNER) {self.localUsername}>'
|
||||
@@ -242,21 +371,49 @@ class User(db.Model, UserMixin):
|
||||
return self.external_email
|
||||
return None
|
||||
|
||||
# Permission Methods
|
||||
# Permission Methods (Discord-style RBAC)
|
||||
def has_permission(self, permission_name):
|
||||
"""Check if user has a specific permission"""
|
||||
"""Check if user has a specific permission through any of their admin roles"""
|
||||
if self.userType == UserType.OWNER:
|
||||
return True # Owners have all permissions
|
||||
elif self.userType == UserType.LOCAL:
|
||||
# Check role-based permissions for local users
|
||||
for role in self.roles:
|
||||
if permission_name in (role.permissions or []):
|
||||
# Check permissions across all admin roles (union of permissions)
|
||||
for role in self.admin_roles:
|
||||
if role.has_permission(permission_name):
|
||||
return True
|
||||
return False
|
||||
elif self.userType == UserType.SERVICE:
|
||||
return False # Service users have no app permissions
|
||||
return False
|
||||
|
||||
def get_all_permissions(self):
|
||||
"""Get all permissions this user has across all their admin roles"""
|
||||
if self.userType == UserType.OWNER:
|
||||
# Owners have all permissions
|
||||
return AdminPermission.query.all()
|
||||
elif self.userType == UserType.LOCAL:
|
||||
# Collect unique permissions from all roles
|
||||
permissions = set()
|
||||
for role in self.admin_roles:
|
||||
permissions.update(role.permissions)
|
||||
return list(permissions)
|
||||
return []
|
||||
|
||||
def get_highest_role_position(self):
|
||||
"""Get the highest position among all admin roles (for hierarchy checks)"""
|
||||
if self.userType == UserType.OWNER:
|
||||
return float('inf') # Owners are above all roles
|
||||
elif self.userType == UserType.LOCAL and self.admin_roles:
|
||||
return max(role.position for role in self.admin_roles)
|
||||
return 0
|
||||
|
||||
def can_manage_role(self, target_role):
|
||||
"""Check if user can manage a specific role (hierarchy check)"""
|
||||
if self.userType == UserType.OWNER:
|
||||
return True
|
||||
user_highest_position = self.get_highest_role_position()
|
||||
return user_highest_position > target_role.position
|
||||
|
||||
# Service-Specific Methods
|
||||
def get_service_type(self):
|
||||
"""Get the service type (only for SERVICE users)"""
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
The user accounts feature is currently disabled. To merge service users into a local account,
|
||||
you need to enable the user accounts feature first.
|
||||
</p>
|
||||
<a href="{{ url_for('settings.user_accounts') }}"
|
||||
<a href="{{ url_for('settings.users_general') }}"
|
||||
class="btn btn-sm btn-warning">
|
||||
<i class="fa-solid fa-cog mr-2"></i>
|
||||
Enable User Accounts Feature
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Add internal_id to media_libraries table
|
||||
|
||||
Revision ID: add_internal_id_media_libs
|
||||
Revises: 6b173daf0089
|
||||
Create Date: 2025-01-27 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import uuid
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7c8d9e0f1a2b'
|
||||
down_revision = '6b173daf0089'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Check if the column already exists (in case of partial migration)
|
||||
connection = op.get_bind()
|
||||
|
||||
# Check if internal_id column exists
|
||||
try:
|
||||
result = connection.execute(sa.text("PRAGMA table_info(media_libraries)"))
|
||||
columns = [row[1] for row in result.fetchall()] # Column names are in index 1
|
||||
has_internal_id = 'internal_id' in columns
|
||||
except:
|
||||
has_internal_id = False
|
||||
|
||||
# Add the column only if it doesn't exist
|
||||
if not has_internal_id:
|
||||
op.add_column('media_libraries', sa.Column('internal_id', sa.String(36), nullable=True))
|
||||
|
||||
# Populate existing records with UUIDs (only for records that don't have internal_id)
|
||||
result = connection.execute(sa.text("SELECT id FROM media_libraries WHERE internal_id IS NULL"))
|
||||
libraries = result.fetchall()
|
||||
|
||||
# Update each library with a unique internal_id
|
||||
for library in libraries:
|
||||
internal_id = str(uuid.uuid4())
|
||||
connection.execute(
|
||||
sa.text("UPDATE media_libraries SET internal_id = :internal_id WHERE id = :id"),
|
||||
{"internal_id": internal_id, "id": library.id}
|
||||
)
|
||||
|
||||
# Create unique index if it doesn't exist
|
||||
try:
|
||||
op.create_index('idx_media_libraries_internal_id', 'media_libraries', ['internal_id'], unique=True)
|
||||
except:
|
||||
# Index might already exist, ignore the error
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove the index and column
|
||||
op.drop_index('idx_media_libraries_internal_id', table_name='media_libraries')
|
||||
op.drop_column('media_libraries', 'internal_id')
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Add index for media_libraries server_id and external_id lookup
|
||||
|
||||
Revision ID: add_media_libraries_index
|
||||
Revises: 6b173daf0089
|
||||
Create Date: 2025-09-29 16:45:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_media_libraries_index'
|
||||
down_revision = '7c8d9e0f1a2b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create index for faster lookups during Kavita ID conversion
|
||||
op.create_index(
|
||||
'idx_media_libraries_server_external',
|
||||
'media_libraries',
|
||||
['server_id', 'external_id'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove the index if rolling back
|
||||
op.drop_index('idx_media_libraries_server_external', table_name='media_libraries')
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 6b173daf0089
|
||||
Revision ID: e96443719543
|
||||
Revises:
|
||||
Create Date: 2025-09-28 15:40:04.955273
|
||||
Create Date: 2025-10-02 18:28:06.220589
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
@@ -10,7 +10,7 @@ import sqlalchemy as sa
|
||||
import app
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6b173daf0089'
|
||||
revision = 'e96443719543'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
@@ -18,6 +18,16 @@ depends_on = None
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('admins_roles',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=80), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('permissions', app.extensions.JSONEncodedDict(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('icon', sa.String(length=100), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('invites',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('token', sa.String(length=64), nullable=False),
|
||||
@@ -117,16 +127,6 @@ def upgrade():
|
||||
with op.batch_alter_table('plugins', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_plugins_plugin_id'), ['plugin_id'], unique=True)
|
||||
|
||||
op.create_table('roles',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=80), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('permissions', app.extensions.JSONEncodedDict(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('icon', sa.String(length=100), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('settings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('key', sa.String(length=100), nullable=False),
|
||||
@@ -189,6 +189,9 @@ def upgrade():
|
||||
sa.Column('plex_uuid', sa.String(length=255), nullable=True),
|
||||
sa.Column('plex_username', sa.String(length=255), nullable=True),
|
||||
sa.Column('plex_thumb', sa.String(length=512), nullable=True),
|
||||
sa.Column('user_role_ids', app.extensions.JSONEncodedDict(), nullable=True),
|
||||
sa.Column('admin_roles_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['admin_roles_id'], ['admins_roles.id'], ),
|
||||
sa.ForeignKeyConstraint(['linkedUserId'], ['users.uuid'], ),
|
||||
sa.ForeignKeyConstraint(['server_id'], ['media_servers.id'], ),
|
||||
sa.ForeignKeyConstraint(['used_invite_id'], ['invites.id'], ),
|
||||
@@ -208,12 +211,16 @@ def upgrade():
|
||||
batch_op.create_index(batch_op.f('ix_users_userType'), ['userType'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_users_uuid'), ['uuid'], unique=True)
|
||||
|
||||
op.create_table('app_user_roles',
|
||||
sa.Column('app_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['app_user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('app_user_id', 'role_id')
|
||||
op.create_table('users_roles',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('icon', sa.String(length=100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('history_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
@@ -265,6 +272,7 @@ def upgrade():
|
||||
|
||||
op.create_table('media_libraries',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('internal_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('server_id', sa.Integer(), nullable=False),
|
||||
sa.Column('external_id', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
@@ -275,6 +283,7 @@ def upgrade():
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['server_id'], ['media_servers.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('internal_id'),
|
||||
sa.UniqueConstraint('server_id', 'external_id', name='_server_library_uc')
|
||||
)
|
||||
op.create_table('media_stream_history',
|
||||
@@ -389,7 +398,7 @@ def downgrade():
|
||||
batch_op.drop_index(batch_op.f('ix_history_logs_event_type'))
|
||||
|
||||
op.drop_table('history_logs')
|
||||
op.drop_table('app_user_roles')
|
||||
op.drop_table('users_roles')
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_users_uuid'))
|
||||
batch_op.drop_index(batch_op.f('ix_users_userType'))
|
||||
@@ -409,7 +418,6 @@ def downgrade():
|
||||
batch_op.drop_index(batch_op.f('ix_settings_key'))
|
||||
|
||||
op.drop_table('settings')
|
||||
op.drop_table('roles')
|
||||
with op.batch_alter_table('plugins', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_plugins_plugin_id'))
|
||||
|
||||
@@ -422,4 +430,5 @@ def downgrade():
|
||||
batch_op.drop_index(batch_op.f('ix_invites_custom_path'))
|
||||
|
||||
op.drop_table('invites')
|
||||
op.drop_table('admins_roles')
|
||||
# ### end Alembic commands ###
|
||||
162
migrations/versions/implement_discord_style_rbac.py
Normal file
162
migrations/versions/implement_discord_style_rbac.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Implement Discord-style RBAC with role hierarchy
|
||||
|
||||
Revision ID: implement_discord_style_rbac
|
||||
Revises: rename_roles_to_admins_roles
|
||||
Create Date: 2025-01-02 14:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import sqlite
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'implement_discord_style_rbac'
|
||||
down_revision = 'e96443719543'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
try:
|
||||
# 1. Create admin_permissions table
|
||||
op.create_table('admin_permissions',
|
||||
sa.Column('id', sa.String(36), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
print("Created admin_permissions table")
|
||||
|
||||
# 2. Update admins_roles table structure
|
||||
# First, check if admins_roles exists, if not create it
|
||||
inspector = sa.inspect(connection)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'admins_roles' not in tables:
|
||||
op.create_table('admins_roles',
|
||||
sa.Column('id', sa.String(36), nullable=False),
|
||||
sa.Column('name', sa.String(80), nullable=False),
|
||||
sa.Column('description', sa.String(255), nullable=True),
|
||||
sa.Column('position', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('color', sa.String(7), nullable=True),
|
||||
sa.Column('icon', sa.String(100), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
print("Created admins_roles table")
|
||||
else:
|
||||
# Add position column to existing admins_roles table
|
||||
try:
|
||||
op.add_column('admins_roles', sa.Column('position', sa.Integer(), nullable=False, server_default='0'))
|
||||
print("Added position column to admins_roles")
|
||||
except Exception as e:
|
||||
print(f"Position column might already exist: {e}")
|
||||
|
||||
# Change id to UUID if it's still integer
|
||||
try:
|
||||
# SQLite doesn't support ALTER COLUMN, so we'll need to recreate the table
|
||||
# For now, we'll keep integer IDs and handle UUID in the application layer
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Could not update ID column: {e}")
|
||||
|
||||
# 3. Create admin_role_permissions junction table
|
||||
op.create_table('admin_role_permissions',
|
||||
sa.Column('role_id', sa.String(36), nullable=False),
|
||||
sa.Column('permission_id', sa.String(36), nullable=False),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['admins_roles.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['permission_id'], ['admin_permissions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('role_id', 'permission_id')
|
||||
)
|
||||
print("Created admin_role_permissions table")
|
||||
|
||||
# 4. Create admin_user_roles_assignments junction table (many-to-many for users and admin roles)
|
||||
op.create_table('admin_user_roles_assignments',
|
||||
sa.Column('user_id', sa.String(36), nullable=False),
|
||||
sa.Column('role_id', sa.String(36), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.uuid'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['admins_roles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'role_id')
|
||||
)
|
||||
print("Created admin_user_roles_assignments table")
|
||||
|
||||
# 5. Update users_roles table to use UUIDs
|
||||
if 'users_roles' not in tables:
|
||||
op.create_table('users_roles',
|
||||
sa.Column('id', sa.String(36), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(7), nullable=True),
|
||||
sa.Column('icon', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
print("Created users_roles table")
|
||||
|
||||
# 6. Create users_roles_assignments junction table
|
||||
op.create_table('users_roles_assignments',
|
||||
sa.Column('user_id', sa.String(36), nullable=False),
|
||||
sa.Column('visual_role_id', sa.String(36), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.uuid'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['visual_role_id'], ['users_roles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'visual_role_id')
|
||||
)
|
||||
print("Created users_roles_assignments table")
|
||||
|
||||
# 7. Remove old columns that are no longer needed
|
||||
try:
|
||||
# Remove admin_roles_id from users (replaced by admin_user_roles junction table)
|
||||
# SQLite doesn't support DROP COLUMN, so we'll keep it for now and ignore it
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Could not remove old columns: {e}")
|
||||
|
||||
# 8. Insert default admin permissions
|
||||
default_permissions = [
|
||||
('manage_users', 'Create, edit, and delete users'),
|
||||
('manage_roles', 'Create, edit, and delete admin roles'),
|
||||
('manage_permissions', 'Assign permissions to roles'),
|
||||
('manage_settings', 'Access and modify application settings'),
|
||||
('view_logs', 'View application logs and audit trails'),
|
||||
('manage_invites', 'Create and manage user invitations'),
|
||||
('manage_servers', 'Configure media servers'),
|
||||
('manage_plugins', 'Install and configure plugins'),
|
||||
]
|
||||
|
||||
for perm_name, perm_desc in default_permissions:
|
||||
try:
|
||||
# Generate a simple UUID-like string for the ID
|
||||
import uuid
|
||||
perm_id = str(uuid.uuid4())
|
||||
connection.execute(sa.text(
|
||||
"INSERT INTO admin_permissions (id, name, description) VALUES (:id, :name, :desc)"
|
||||
), {"id": perm_id, "name": perm_name, "desc": perm_desc})
|
||||
except Exception as e:
|
||||
print(f"Could not insert permission {perm_name}: {e}")
|
||||
|
||||
connection.commit()
|
||||
print("Database migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration error: {e}")
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Drop all the new tables
|
||||
op.drop_table('users_roles_assignments')
|
||||
op.drop_table('admin_user_roles_assignments')
|
||||
op.drop_table('admin_role_permissions')
|
||||
op.drop_table('admin_permissions')
|
||||
|
||||
# Remove position column from admins_roles (if possible in SQLite)
|
||||
try:
|
||||
op.drop_column('admins_roles', 'position')
|
||||
except:
|
||||
pass
|
||||
Reference in New Issue
Block a user