13
votes

I've had great success using the state_machine and love the class methods it dynamically creates through just few lines of code.

However, I'm not sure how to proceed with the system I'm creating. I'm currently developing a system where users have many roles. So it's not a simple as having a user whose state goes from unconfirmed to confirmed and then potentially to admin.

A user now has many roles, and can be a potential, a cyclist, a coorinator, a manger, a forum admin, a store admin, a super admin and a fundraiser.

So the hierarchy goes like this:

superadmin

forum admin, store admin

cyclist, coordinator, manger, fundraiser

potential

However, one state machine won't cut it here, because it's completely possible that one user can have all of the above roles at the same time.

I'm implementing my own class methods like this to sort of emulate state-machine somewhat:

class User < ActiveModel::Base
    has_many :jobs
    has_many :roles, through: :jobs

    def role_array
        self.roles.pluck(:role)
    end

    def has_role?(role)
        role_array.include?(role)
    end

    # checking
    def is_superadmin?
        role_array.include?('superadmin')
    end


    # changing
    def add_role(role)
       self.update_attributes(accepted_at: Time.now) if self.is_only_potential?
       self.user_roles.create(role_id: Role.find_by(role: role).id ) if !self.has_role?(role)
    end

    def remove_role(role)
        self.user_roles.find_by( role_id: Role.find_by(role: role).id ).destroy if self.has_role?(role)
    end

    def make_superadmin!
        add_role('superadmin')
    end

    def denounce_superadmin!
        remove_role('superadmin')
    end

end

And it's just a bit of a faff. So my questions are:

1) Am I doing it right? How would you handle users with multiple roles?

2) Even if I am doing it right, I'd like to create a state_machine-esque DSL, so when I need to create a new role, let's say 'runner', I can just do something like this in my model:

class User < ActiveModel::Base
    has_many :jobs
    has_many :roles, through: :jobs

    multiroles initial: :potential do
        roles [:superadmin, :forum_admin, :store_admin, :cyclist, :coordinator, :manager, :fundraiser, :potential]
         # dynamically creates the above methods for getting and setting for all roles
    end

How should I create that multiroles method? Inside lib? ready to be packaged off as my first Gem?

I have no idea how to dynamically create methods, but I'd like to start :)

Just a thought, maybe the multiroles method could dynamically get all the roles via Roles.all and automatically add the above methods! Maybe even take care of the has_many :jobs has_many :roles, through: :jobs

Also, how should I be authenticating these roles? I'm currently doing this in a before block in my controllers:

def only_superadmins
    redirect_to root_url if !current_user.has_role?('superadmin')
end

I also have a bunch of these methods in my application controller, only_superadmins, only_cyclists ect and I call them via the before_method method in various sub-controllers.

Is this okay? Should I be using cancan or something?

If I am doing it right, I'm wondering how I should dynamically create these methods with my Gem. I'm thinking something along these lines:

class panel_controller < ApplicationController   
    allowed_roles [:super_admin, :forum_admin, :store_admin]
end

and the allowed_roles method would create these methods

def allowed_roles(role_array)
    role_array.each do |role|
         define "only_#{role.to_s}s" do |arg|
            redirect_to root_url if !current_user.has_role?(arg.to_s)
         end
    end
end

So that would programatically create these methods:

def only_super_admins
    redirect_to root_url if !current_user.has_role?('super_admin')
end

def only_forum_admins
    redirect_to root_url if !current_user.has_role?('forum_admin')
end


def only_store_admins
    redirect_to root_url if !current_user.has_role?('store_admin')
end

While I don't see why that wouldn't work, that doesn't strike me as too efficient.

Maybe allowed_roles should look like this:

def allowed_roles(wanted_roles)
    redirect_to root_url unless (current_user.role_array & wanted_roles).empty? # it's ONLY empty when any of the current_user roles exists in the wanted_roles array
end

I just want some pointers really :)

How do I create a gem to make the allowed_roles method available to the controllers and multiroles available to the user model?

Can cancan manage multiple roles like this? Should I just be using that?

1

1 Answers

18
votes

Resumed answer:

For handling roles for your model, a good options is to use the gem rolify. With it you can easily define as many roles you want and associate as many roles as you want to your User. It is simple to use, just follow the official documentation here.

CanCan (or its sucessor CanCanCan) is used to handle permissions. You will define what an User with each role (defined with rolify) is authorized to do in the file app/models/ability.rb. Then, in controllers or views, you just verify if user is authorized to perform an action for a resource. For example, in your controller you verify the authorization like @comment = Comment.new(params); authorize! :create, @comment, and in your view you verify the authorization like if can? :create, Comment. Refeer to the official documentation here for learning how to setup and use CanCan.

Aplying these to your specific problem:

Add Rolify (gem "rolify") and CanCan (gem "cancan") gems to your Gemfile.

Execute the rails shell command rails g rolify Role User to create a new class named Role (or use the name you prefer) and add some class methods in your existing class User. As the new class Role will add a table Role to your database, you have to run rake db:migrate (when using ActiveRecord).

Add resourcify to any class that will be accessed by the User. For example:

class Forum < ActiveRecord::Base
  resourcify
end

Once you have done these steps, your User class will be equiped with the methods add_role, remove_role and has_role and you can use them to add as many roles as you wish:

user.add_role :superadmin
user.add_role :fundraiser

user.has_role? :superadmin
# >> true
user.has_role? :fundraiser
# >> true

And you can even scope a role to one resource or instance:

user.add_role :cyclist, Forum
user.add_role :coordinator, Forum.first

user.has_role? :cyclist, Forum
# >> true
user.has_role? :cyclist, Store
# >> false
user.has_role? :coordinator, Forum.first
# >> true
user.has_role? :coordinator, Forum.second
# >> false

So you could write your User class like this:

class User < ActiveModel::Base
  rolify
  has_many :jobs

  # checking
  def is_superadmin?
      self.has_role?('superadmin')
  end

  # changing
  def add_new_role(role)
     self.update_attributes(accepted_at: Time.now) if self.is_only_potential?
     self.add_role(role)
  end

  def make_superadmin!
      add_new_role('superadmin')
  end

  def denounce_superadmin!
      remove_role('superadmin')
  end
end

To authenticate those roles you may use CanCan. Execute the rails shell command rails g cancan:ability to generate the file app/models/ability.rb where you will define the permissions for your roles.

class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new # guest user

    if user.has_role? :superadmin
      can :manage, All  # can manage (Read, Create, Update, Destroy, ...) everything
    elsif user.has_role? :forum_admin
      can :manage, Forum  # can manage (Read, Create, Update, Destroy, ...) any Forum
    elsif user.has_role? :store_admin
      can :manage, Store do |store|  # Can manage only its own store
        store.try(:user) == user
      end
    elsif user.has_role? :forum_member
      can :create, Post do |post|
        if post.forum.members.include? user
          true
        end
      end
      can :destroy, Post do |post|
        post.try(:user) == user
      end
      can :update, Post do |post|
        post.try(:user) == user
      end
    elsif ...

    else # Users without role
      can :read, All
    end
  end
end

In your controllers you may call the authorize! method. For example:

# app/controllers/posts_controller.rb
def create
  @post = Post.new(params[:post])
  @post.user = current_user
  authorize! :create, @post
  if @post.save
    redirect_to @post
  else
    render :action => 'new'
  end
end

Or you could include the faloowing at the beggining of your controller and the resource is automatically loaded and authorized (or not) before each action:

class PostController < ApplicationController
  load_and_authorize_resource :post

  ...

def create
  authorize! :create, @post
  if @post.save
    redirect_to @post
  else
    render :action => 'new'
  end
end

Watch this tutorial at RailsCast for getting started with CanCan.

I hope this can help to guide you trough your problem.