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!