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