使用cancancan做权限管理

cancancan是Rails中的运用较为广泛的权限管理gem,源码:GitHub库wiki

目前最新的版本是2.2.0, 安装部分可见文档,这里我记录下自己在定义权限时遇到的问题,同时附上验证权限及Rspec下如何测试Ability。

定义权限

cancancan的wiki中Defining Abilities中给到了权限定义的例子:

较常规的,比如:

## ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    can :read, :all
    if user.present?
      can :manage, Post, user_id: user.id
      can :manage, Comment
      can :read, Tag, released: true
    end
    if user.admin?
      can :manage, :all
    end
  end
end

简单说一下,can :manage, Post, user_id: user.id, 表明post含有字段user_id,且当前的user.id等于project的user_id,可以理解成对于所有current_user创建的post,都有manage的权限。

can :read, Tag, released: true则表明,用户只能阅读已经发布的tag。

需要注意的是,Ability的初始化,默认是在application_controller中添加了helper method:current_ability, 可以在它的源码module CanCan的定义中看到,调用了钩子方法included,当include CanCan时,这些method会自动引入:

def self.included(base)
  base.extend ClassMethods
  base.helper_method :can?, :cannot?, :current_ability if base.respond_to? :helper_method
  base.class_attribute :_cancan_skipper
end

而module CanCan是自动included 到所有的controller的,这样所有的controller都拥有了current_ability 这个helper method。

我们可以通过在ApplicationController中自定义current_ability来定制权限分配。

看一个稍微有点不一样的场景:

现有model: project,user,member,task,其中project与task是一对多的关系,member是连接user与project之间多对多关系的中间表,4个model的表结构如下:

# Table name: projects
#
#  id         :bigint(8)        not null, primary key
#  name       :string

# Table name: users
#
#  id                     :bigint(8)        not null, primary key
#  email                  :string           default(""), not null
#  name                   :string
#  admin                  :boolean          default(FALSE)
......[users使用devise创建,其他的attributes省去]

# Table name: task
#
#  id           :bigint(8)        not null, primary key
#  title        :string
#  content      :text
#  project_id   :bigint(8)

# Table name: members
#
#  id         :bigint(8)        not null, primary key
#  role       :string           [owner, member]
#  project_id :bigint(8)
#  user_id    :bigint(8)

需要定义权限如下:

  • user是admin时,拥有最高权限,即can :manage, :all

  • User不是admin时,根据user在project中的角色来决定他的权限,比如role为owner时,可以读和改project,manage task,role为member时,则只能读project, manage task。

    这里就相当于initialize ability的时候,user是参数,需要判断是否是admin,另外需要创建一个新的method,将member作为参数传递过去,根据member的role来定义权限。

    我们先来定义从user出发的权限:

    ## ability.rb
    class Ability
      include CanCan::Ability
    
      def initialize(user)
        return unless user
        if user.admin?
          can :manage, :all
          return
        end
      end
    end
    

    而current_ability的默认定义是这样的:

    def current_ability
      @current_ability ||= Ability.new(current_user)
    end
    

    这样user的权限是OK了,但是member呢?我们需要基于项目给user授权,同一个user在不同的项目中可能有不同的角色。我们需要重新修改下ability的初始化,把user和member的授权都定义在单独的method中:

    class Ability
      include CanCan::Ability
    
      def initialize(&block)
        block.(self)
      end
    
      def apply_user_permissions(user)
        return unless user
    
        if user.admin?
          can :manage, :all
          return
        end
        can :read, Project, members: { user_id: user.id }
      end
    
      def apply_member_permissions(member)
        return unless member
    
        if member.role.owner?
          can [:read, :update], member.project
          can :manage, Task
        end
    
        if member.role.member?
          can :read, member.project
          can :manage, Task
        end
      end
    end
    

    这样权限就定义好了,要如何使用呢?

    application_controller.rb中,添加current_ability method:

      def current_ability
        @current_ability ||= Ability.new { |a| a.apply_user_permissions(current_user) }
      end
    

    传递给apply_user_permissions的参数是current_user,类似地,调用apply_member_permissions,传递的是current_member,而这个权限的初始化,则在project和task中,遵循着DRY原则,我们新建一个BaseController来定义current_ability, 然后让ProjectsController, TasksController继承它。

    def BaseController < ApplicationController
      def current_ability
        super.tap { |a| a.apply_member_permissions(current_member) }
      end
    end
    

    这里,先调用了ApplicationController中的current_ability,判断如果user是admin,则直接manage all,如果不是admin,同时也不是nil,则调用apply_member_permissions来授权,那么current_member如何定义呢?参考define a helper method like devise’s current_user, 我们可以这样写:

    def current_member
      @current_member ||= current_project&.members&.where(user_id: current_user)&.take
    end
    

    首先找出当前project的所有members,然后在members中找出user_id == current_user.id的那一条member纪录即可。【此处,current_project的定义略去,可参见define a helper method like devise’s current_user

    将这个method添加到BaseController中:

    def BaseController < ApplicationController
      def current_member
        @current_member ||= current_project&.members&.where(user_id: current_user)&.take
      end
    
      def current_ability
        super.tap { |a| a.apply_member_permissions(current_member) }
      end
    end
    

    让ProjectsController, TasksController都继承自BaseController:

    ### projects_controller.rb
    class ProjectsController < BaseController
      ......
    end
    
    ### tasks_controller.rb
    class TasksController < BaseController
      ......
    end
    

    OK!! 下面看看如何验证权限。

验证权限

验证权限其实比较简单,在view中直接添加if语句即可:

<%= link_to "New Post", new_post_path if can? :create, Post %>

<%= link_to "edit Post", edit_post_path(@post) if can? :edit, @post %>

<%= link_to @post.title, post_path(@post) if can? :read, @post %>

<%= link_to "destroy Post", post_path(@post), method: :delete if can? :destroy, @post %>

<% if cannot? :destroy, @post %>
 <span class="permission-message">you are not allowed to delete this post</span>
<% end %>

加载资源并授权

非常好用的一个method:load_and_authorize_resource, 顾名思义,就是加载资源并授权,可以拆分成:load_resource, authorize_resource来单独使用。

看个简单的例子:

class PostsController < ApplicationController
 load_and_authorize_resource
 def show
 end
end

等同于:

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    authorize! :read, @post
  end
end

你也可以按需择取,比如你load_resource的方式不是通过 Post.find(params[:id]),而是自定义了一个current_post的helper method,则可以这样:

class PostsController < ApplicationController
  before_action -> { @post = current_post }
  authorize_resource
  def show
  end
end

有关Rspec

Rspec部分比较简单,参见test Ability Rspec.

这里,直接用上面定义的ability为例,来写一写对应的Rspec。

先写好factories:

### users.rb
FactoryBot.define do
  factory :user do
    email { Faker::Internet.email }
    name { Faker::Name.name }
    password "password"
    password_confirmation "password"

    trait :admin do
      admin true
    end
  end
end

### projects.rb
FactoryBot.define do
  factory :project do
    name "My project"
  end
end

### members.rb
FactoryBot.define do
  factory :member do
    project
    user

    trait :admin do
      role "admin"
    end

    trait :member do
      role "member"
    end
  end
end

spec/models/ability_spec.rb添加如下内容:

require 'rails_helper'
require 'cancan/matchers'

RSpec.describe Ability, type: :model do
  describe "#apply_user_permissions" do
    context "admin" do
      let!(:admin) { create :user, :admin }
      let!(:project) { create :project }
      subject(:ability) { Ability.new { |a| a.apply_user_permissions(admin) } }
      it {
        is_expected.to be_able_to(:manage, :all)
      }
    end

    context "not admin but a member of a project" do
      let!(:user) { create :user }
      let!(:project) { create :project }
      let!(:member) { create :member, project: project, user: user, role: "member" }
      subject(:ability) { Ability.new { |a| a.apply_user_permissions(user) } }
      it {
        is_expected.to be_able_to(:read, project)
      }
    end

    context "neither admin nor member of a project" do
      let!(:user) { create :user }
      let!(:project) { create :project }
      subject(:ability) { Ability.new { |a| a.apply_user_permissions(user) } }
      it {
        is_expected.not_to be_able_to(:read, project)
      }
    end
  end

  describe "#apply_member_permissions" do

    context "admin" do
      let!(:project) { create :project }
      let!(:member) { create :member, :admin, project: project }
      subject(:ability) { Ability.new { |a| a.apply_member_permissions(member) } }
      it {
        is_expected.to be_able_to([:read, :update], project)
        is_expected.to be_able_to(:manage, Task)
      }
    end

    context "member" do
      let!(:project) { create :project }
      let!(:member) { create :member, :member, project: project }
      subject(:ability) { Ability.new { |a| a.apply_member_permissions(member) } }
      it {
        is_expected.to be_able_to(:read, project)
        is_expected.to be_able_to(:manage, Task)
      }
    end
  end
end

OK ! 终端跑一下测试,pass!

参考

cancancan