From 874d68a308f7ee6b91c6c04ba0ce14092a8214aa Mon Sep 17 00:00:00 2001
From: Teal <git@teal.is>
Date: Tue, 4 Mar 2025 18:18:10 +0100
Subject: [PATCH] Add RBAC, convert existing roles

---
 app/controllers/application_controller.rb     | 23 +++++++--
 app/models/permission.rb                      |  6 +++
 app/models/role.rb                            |  9 ++++
 app/models/role_permission.rb                 |  4 ++
 app/models/user.rb                            | 36 +++++++++----
 app/models/user_role.rb                       |  4 ++
 db/migrate/20250304094401_create_roles.rb     | 10 ++++
 .../20250304094406_create_user_roles.rb       | 10 ++++
 .../20250304094410_create_permissions.rb      | 10 ++++
 .../20250304094414_create_role_permissions.rb | 10 ++++
 .../20250304101405_migrate_to_rbac_system.rb  | 51 +++++++++++++++++++
 ...0959_remove_shiftcoordinator_from_users.rb |  9 ++++
 12 files changed, 168 insertions(+), 14 deletions(-)
 create mode 100644 app/models/permission.rb
 create mode 100644 app/models/role.rb
 create mode 100644 app/models/role_permission.rb
 create mode 100644 app/models/user_role.rb
 create mode 100644 db/migrate/20250304094401_create_roles.rb
 create mode 100644 db/migrate/20250304094406_create_user_roles.rb
 create mode 100644 db/migrate/20250304094410_create_permissions.rb
 create mode 100644 db/migrate/20250304094414_create_role_permissions.rb
 create mode 100644 db/migrate/20250304101405_migrate_to_rbac_system.rb
 create mode 100644 db/migrate/20250304110959_remove_shiftcoordinator_from_users.rb

diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index fe37fee..2002cc7 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -6,13 +6,28 @@ class ApplicationController < ActionController::Base
   def configure_permitted_parameters
     devise_parameter_sanitizer.permit(:sign_up, keys: [ :invitation_token ])
     devise_parameter_sanitizer.permit(:account_update) do |u|
-      u.permit(:name, :email, :password, :password_confirmation, :avatar_color, :darkmode, :languages_from, :languages_to, :telegram_username, :current_password)
+      u.permit(:name, :email, :password, :password_confirmation, :avatar_color, :darkmode, :languages_from,
+               :languages_to, :telegram_username, :current_password)
     end
   end
 
   def authorize_shiftcoordinator
-    unless current_user.shiftcoordinator?
-      render plain: "Forbidden", status: :forbidden
-    end
+    authorize_role("shift_coordinator")
+  end
+
+  def redirect_back_with_error(message)
+    redirect_back(fallback_location: root_path, alert: message)
+  end
+
+  def authorize_role(role_name)
+    return if current_user&.has_role?(role_name)
+
+    render plain: "Forbidden", status: :forbidden
+  end
+
+  def authorize_permission(permission_name)
+    return if current_user&.has_permission?(permission_name)
+
+    render plain: "Forbidden", status: :forbidden
   end
 end
diff --git a/app/models/permission.rb b/app/models/permission.rb
new file mode 100644
index 0000000..3f810b4
--- /dev/null
+++ b/app/models/permission.rb
@@ -0,0 +1,6 @@
+class Permission < ApplicationRecord
+  has_many :role_permissions, dependent: :destroy
+  has_many :roles, through: :role_permissions
+
+  validates :name, presence: true, uniqueness: { case_sensitive: false }
+end
diff --git a/app/models/role.rb b/app/models/role.rb
new file mode 100644
index 0000000..927be3a
--- /dev/null
+++ b/app/models/role.rb
@@ -0,0 +1,9 @@
+class Role < ApplicationRecord
+  has_many :user_roles, dependent: :destroy
+  has_many :users, through: :user_roles
+
+  has_many :role_permissions, dependent: :destroy
+  has_many :permissions, through: :role_permissions
+
+  validates :name, presence: true, uniqueness: { case_sensitive: false }
+end
diff --git a/app/models/role_permission.rb b/app/models/role_permission.rb
new file mode 100644
index 0000000..bee3877
--- /dev/null
+++ b/app/models/role_permission.rb
@@ -0,0 +1,4 @@
+class RolePermission < ApplicationRecord
+  belongs_to :role
+  belongs_to :permission
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index ecae167..4940c14 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,6 +3,9 @@ class User < ApplicationRecord
   has_many :assignments
   has_many :candidates
 
+  has_many :user_roles, dependent: :destroy
+  has_many :roles, through: :user_roles
+
   enum :darkmode, auto: 0, light: 1, dark: 2
   validates :darkmode, inclusion: { in: %w[auto light dark] }
 
@@ -11,10 +14,12 @@ class User < ApplicationRecord
   validates :email, uniqueness: { case_sensitive: false, message: "already in use" }, allow_nil: true, allow_blank: true
 
   before_validation :cleanup_languages
-  validates :languages_from, format: { with: /\A([a-z][a-z])(,[a-z][a-z])*\z/, message: "please use comma-separated two-letter codes" }, allow_blank: true
+  validates :languages_from,
+            format: { with: /\A([a-z][a-z])(,[a-z][a-z])*\z/, message: "please use comma-separated two-letter codes" }, allow_blank: true
   validates :languages_from, length: { maximum: 14 }
 
-  validates :languages_to, format: { with: /\A([a-z][a-z])(,[a-z][a-z])*\z/, message: "please use comma-separated two-letter codes" }, allow_blank: true
+  validates :languages_to,
+            format: { with: /\A([a-z][a-z])(,[a-z][a-z])*\z/, message: "please use comma-separated two-letter codes" }, allow_blank: true
   validates :languages_to, length: { maximum: 14 }
 
   validates :invitation_token, presence: true, on: :create
@@ -35,9 +40,9 @@ class User < ApplicationRecord
 
   def self.leaderboard
     all.map { |u| [ u.name, u.workload_minutes ] }
-      .sort_by { |_, workload| -workload }
-      .reject { |_, workload| workload.zero? }
-      .to_h
+       .sort_by { |_, workload| -workload }
+       .reject { |_, workload| workload.zero? }
+       .to_h
   end
 
   class Session < ApplicationRecord
@@ -49,7 +54,6 @@ class User < ApplicationRecord
     end
   end
 
-
   def errors
     super.tap { |errors| errors.delete(:password, :blank) if password.nil? }
   end
@@ -73,7 +77,7 @@ class User < ApplicationRecord
   end
 
   def set_avatar_color
-    return unless self.avatar_color.nil?
+    return unless avatar_color.nil?
 
     r = rand(256)
     g = rand(256)
@@ -87,7 +91,19 @@ class User < ApplicationRecord
   end
 
   def workload_minutes
-    Assignment.includes(:session).where(user: self).sum { | a | a.session.duration_minutes }
+    Assignment.includes(:session).where(user: self).sum { |a| a.session.duration_minutes }
+  end
+
+  def has_role?(role_name)
+    roles.exists?(name: role_name)
+  end
+
+  def has_permission?(permission_name)
+    roles.joins(:permissions).exists?(permissions: { name: permission_name })
+  end
+
+  def shiftcoordinator?
+    has_role?("shift_coordinator")
   end
 
   private
@@ -98,7 +114,7 @@ class User < ApplicationRecord
   end
 
   def cleanup_languages
-    self.languages_from = self.languages_from&.gsub(/\s+/, "")&.downcase
-    self.languages_to = self.languages_to&.gsub(/\s+/, "")&.downcase
+    self.languages_from = languages_from&.gsub(/\s+/, "")&.downcase
+    self.languages_to = languages_to&.gsub(/\s+/, "")&.downcase
   end
 end
diff --git a/app/models/user_role.rb b/app/models/user_role.rb
new file mode 100644
index 0000000..e9a05c5
--- /dev/null
+++ b/app/models/user_role.rb
@@ -0,0 +1,4 @@
+class UserRole < ApplicationRecord
+  belongs_to :user
+  belongs_to :role
+end
diff --git a/db/migrate/20250304094401_create_roles.rb b/db/migrate/20250304094401_create_roles.rb
new file mode 100644
index 0000000..9e64670
--- /dev/null
+++ b/db/migrate/20250304094401_create_roles.rb
@@ -0,0 +1,10 @@
+class CreateRoles < ActiveRecord::Migration[8.0]
+  def change
+    create_table :roles do |t|
+      t.string :name
+      t.text :description
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20250304094406_create_user_roles.rb b/db/migrate/20250304094406_create_user_roles.rb
new file mode 100644
index 0000000..7211991
--- /dev/null
+++ b/db/migrate/20250304094406_create_user_roles.rb
@@ -0,0 +1,10 @@
+class CreateUserRoles < ActiveRecord::Migration[8.0]
+  def change
+    create_table :user_roles do |t|
+      t.references :user, null: false, foreign_key: true
+      t.references :role, null: false, foreign_key: true
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20250304094410_create_permissions.rb b/db/migrate/20250304094410_create_permissions.rb
new file mode 100644
index 0000000..7e1f445
--- /dev/null
+++ b/db/migrate/20250304094410_create_permissions.rb
@@ -0,0 +1,10 @@
+class CreatePermissions < ActiveRecord::Migration[8.0]
+  def change
+    create_table :permissions do |t|
+      t.string :name
+      t.text :description
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20250304094414_create_role_permissions.rb b/db/migrate/20250304094414_create_role_permissions.rb
new file mode 100644
index 0000000..4b6474e
--- /dev/null
+++ b/db/migrate/20250304094414_create_role_permissions.rb
@@ -0,0 +1,10 @@
+class CreateRolePermissions < ActiveRecord::Migration[8.0]
+  def change
+    create_table :role_permissions do |t|
+      t.references :role, null: false, foreign_key: true
+      t.references :permission, null: false, foreign_key: true
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20250304101405_migrate_to_rbac_system.rb b/db/migrate/20250304101405_migrate_to_rbac_system.rb
new file mode 100644
index 0000000..7d22f95
--- /dev/null
+++ b/db/migrate/20250304101405_migrate_to_rbac_system.rb
@@ -0,0 +1,51 @@
+class MigrateToRbacSystem < ActiveRecord::Migration[8.0]
+  def up
+    # Create roles
+    shift_coordinator_role = Role.create!(name: 'shift_coordinator', description: 'Can manage session assignments and scheduling')
+    events_admin_role = Role.create!(name: 'events_admin', description: 'Can manage conferences and all sub-resources')
+
+    # Create permissions
+    manage_assignments = Permission.create!(name: 'manage_assignments', description: 'Can create and delete assignments')
+    manage_conferences = Permission.create!(name: 'manage_conferences', description: 'Can create, edit, and delete conferences')
+    manage_sessions = Permission.create!(name: 'manage_sessions', description: 'Can create, edit, and delete sessions')
+    manage_speakers = Permission.create!(name: 'manage_speakers', description: 'Can create, edit, and delete speakers')
+    manage_stages = Permission.create!(name: 'manage_stages', description: 'Can create, edit, and delete stages')
+
+    # Associate permissions with roles
+    shift_coordinator_role.permissions << manage_assignments
+
+    events_admin_role.permissions << manage_conferences
+    events_admin_role.permissions << manage_sessions
+    events_admin_role.permissions << manage_speakers
+    events_admin_role.permissions << manage_stages
+
+    # Migrate existing shift coordinators to the new role system
+    User.where(shiftcoordinator: true).find_each do |user|
+      user.roles << shift_coordinator_role
+      puts "Migrated user #{user.name} to shift_coordinator role"
+    end
+
+    # Add a column to track migration completion
+    add_column :users, :migrated_to_rbac, :boolean, default: false
+
+    # Mark all users as migrated
+    User.update_all(migrated_to_rbac: true)
+
+    # Only remove the shiftcoordinator column after we are sure all users are migrated
+    # We'll do this in a separate migration after verifying
+  end
+
+  def down
+    # Restore shift coordinator status
+    if column_exists?(:users, :shiftcoordinator) && column_exists?(:users, :migrated_to_rbac)
+      User.where(migrated_to_rbac: true).find_each do |user|
+        user.update(shiftcoordinator: user.has_role?('shift_coordinator'))
+      end
+    end
+
+    # Remove the migration tracking column if it exists
+    remove_column :users, :migrated_to_rbac if column_exists?(:users, :migrated_to_rbac)
+
+    # No need to delete roles and permissions as they will be dropped with their tables
+  end
+end
diff --git a/db/migrate/20250304110959_remove_shiftcoordinator_from_users.rb b/db/migrate/20250304110959_remove_shiftcoordinator_from_users.rb
new file mode 100644
index 0000000..a9e2647
--- /dev/null
+++ b/db/migrate/20250304110959_remove_shiftcoordinator_from_users.rb
@@ -0,0 +1,9 @@
+class RemoveShiftcoordinatorFromUsers < ActiveRecord::Migration[8.0]
+  def up
+    remove_column :users, :shiftcoordinator
+  end
+
+  def down
+    add_column :users, :shiftcoordinator, :boolean, default: false
+  end
+end
-- 
GitLab