15 November 2014

简介

权限存取设计是在开发 Application 中相当棘手的问题。

在网站开始建设的初期,通常这样的问题并不会浮现,毕竟一般人的需求大半只会有 user 和 admin 两种角色。但是随著网站演化,更多的业务需求浮现,第三种角色的出现,通常就会把原本干净的 code 弄得肮脏不堪。

多种角色的权限设计难题

当只有 user 和 admin 的情况下,你可以在 view 里面单纯的做出这样的设计

<% if user.is_admin ? %>
  <%= link_to("Admin Pannel", admin_panel_path ) %>
<% end %>

并且在 controller 里面加上权限判断

class Admin::ArticleController < ApplicationController
  before_filter :require_is_admin
end

但一段时间之后,出现这样的需求:

  • 使用者可以被设定为「editor」
  • 拥有「editor」角色的使用者,可以进入 admin 后台发表、编辑文章
  • 拥有「edtior」角色的使用者,进入 admin 后台内的活动范围仅限缩在文章后台内
  • 拥有「edtior」角色的使用者,进入 admin 后台内,不可以看到其他后台选项。

身为开发者的你,要如何在现有后台内加入这样的设计?

不用实际动手写也知道,若如以往使用 if / else 的设计,Helper / Controller / View 铁定变成一团血肉模糊。

抱怨不能解决问题,但世界上是否存在干净的解答?

答案就是:「Rule Engine」。

Rule-engine based authorization library: Cancan

「针对多种条件执行多种动作」,此类的需求,无论是使用 if / else,甚至是 case when,架构还是不免会一团混乱。与其承袭旧思路,不如启用新想法 - 「Rule Engine」:预先设计撰写一套逻辑规则引擎,而后程序针对预设的规则进行逻辑判断后执行。

而「角色权限」的设计需求上,正特别适合用 Rule Engine 这样的观念去建构。Rails 中的授权库(authorization library) cancan 正是以Rule Engine作为基础。

Cancan的特点1: 接口简单,其把权限判定逻辑从 Helper / Controller / View 中, 移到 app/models/ability.rb,从而实现

  • View 只需要判断是否可以执行动作,而不必问是否有权限
<% if can? :update, @article %>
  <%= link_to "Edit", edit_article_path(@article) %>
<% end %>
  • Controller 无需手动判断权限
class ArticlesController < ApplicationController
  authorize_resource

  def show
    # @article is already authorized
  end
end

实际上,权限判断在view和controller通常是雷同的,分散处理常常故此失彼。所以,Cancan的第二个特点是: 权限中心化管理, 即将权限存取,则全交给 app/models/ability.rb 去判断处理。

class Ability
  include CanCan::Ability

  def initialize(user)

    if user.blank?
      # not logged in
      cannot :manage, :all
      basic_read_only
    elsif user.has_role?(:admin)
      # admin
      can :manage, :all
    elsif user.has_role?(:member)
      
      can :create, Topic
      can :update, Topic do |topic|
        (topic.user_id == user.id)
      end
      
      can :destroy, Topic do |topic|
         (topic.user_id == user.id)
      end
      
      basic_read_only
    else
      # banned or unknown situation
      cannot :manage, :all
      basic_read_only
    end

  end

  protected

  def basic_read_only
    can :read,    Topic
    can :list,    Topic
    can :search,  Topic
  end
end

使用Cancan 的限制:RESTful controller (resource)

初学者不太理解 cancan 两个接口函数:load_and_authorize_resourceauthorize_resource

此外,cancan 也没在Readme中说明其架构的限制,即:

  • 必须为 RESTful resource (cancan 直接假设了你一定使用 RESTful,毕竟这年头谁还在写 non-RESTful …?)
  • resource 必须与 Controller 同名(@article 与 ArticlesController)

这两条限制,也是Rails默认的配置,所以,使用过 cancan 的人,大概都「猜到」规则好像是这样?

其实,其source code 里面就写的很清楚

load_and_authorize_resource

load_and_authorized_resource 做了两件事:

def load_and_authorize_resource
   load_resource
   authorize_resource
end

load_resource
authorize_resource

load_resourceload_resource_instance的符号别名(:loard_resource => load_resource_instance)

def load_resource_instance
  if !parent? && new_actions.include?(@params[:action].to_sym)
    build_resource
  elsif id_param || @options[:singleton]
    find_resource
  end
end

okay,这段代码的意思是如果你在 Controller 里面下加入load_resource,cancan 会自作聪明的帮你 自动 在每一个 action中加一个instance 。

class ArticlesController < ApplicationController
  load_resource

  def new
  end

  def show
    # @article is already loaded
  end
end

如果是 new 这个 action,效果会等于

def new
  @article = Article.new
end

如果是 show 这个 action,效果会等于

def show
  @article = Article.find(params[:id])   
end

有好处也有坏处,好处是…你不需要自己打一行 code,坏处就是不熟 cancan 的人,找不到 @article 在哪里会惊慌失措…

load_resource 还有一些其他进阶用法,详细介绍见controller_additions.rb

authorize_resource

authorize_resource 就是对 resource 判断权限(根据 CanCan::Ability 里的权限表)。

而这个 resource 必定是与同名的 instance。

如果是 ArticlesController 对应的必然是 @article。

但是你会想说这样惨了?万一我在 ArticlesController 里面要用 @post 怎么办呢?

可以在 controller 里面指定 资源实例的名字: authorize_resource :post

class ArticlesController < ApplicationController
  authorize_resource :post
  
  def new
    @post = Article.new
  end
  
  def show
    @post = Article.find(params[:id])
  end
end

Ability 代码如下:

can :read, Post
can :create, Post
can :update, Post

resource 规则小结

所以 cancan 里面的 resource 第一个会去吃 controller 的名称当成 resource name,如果是 ArticlesController,instance 就会是 @article,而在 ability 里面就会是 can :read, Article。这是在假设你已经使用同名设计 resource & controller 的情况下。

如果非同名。你可以做出指定:authorize_resource :post,虽然是 ArticlesController,但是这一组的 resource 名称为 post,所以 instance 就会是 @post,而在 ability 里面就会是 can :read, Post。

一般开发者常会误会的是

  • ability 会绑到 model,实际上不是
  • controller 名称要与 @instance 名称相同,实际上不一定
  • @instance 要与 model 同名,实际上不用
  • ability 吃的应该是 controller name,实际上不一定(吃的是 resource name,且可以被指定)。

Cancan 吃的是 resource,而且自作聪明的假设了大家「应该」都同名,而且 README example 也是使用「同名」,才会造成了这么多的误解…

如果你有更多疑问,可以直接看 controller_resource.rb的元代码, 相信会让你对整个架构更加的清楚… 下面介绍ability的设计。

角色判断 current_ability

这是一段普通的 ability.rb 权限范例 code。

class Ability
  include CanCan::Ability

  def initialize(user)

    if user.blank?
      # not logged in
      cannot :manage, :all
      basic_read_only
    elsif user.has_role?(:admin)
      # admin
      can :manage, :all
    end

  end

  protected

  def basic_read_only
    can :read,    Topic
    can :list,    Topic
    can :search,  Topic
  end
end

一般开发者最有疑问的是def initialize(user)这一段程序码中的 user 到底是怎么来的?怎么会没头没尾的天外飞来一个 user,然后对这个 user 进行角色判断就可以动了?

这一段要追溯到lib/controller_additions.rb 中的这一段 current_ability。

cancan 里面去判断是否有权限的一直是 current_abibilty,而 current_abibilty initialize 的方式就是塞 current_user 进去。

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

所以 initialize(user) 里的 if user.blank? 其实就等于 if current_user.blank?(若没登入)。

这样去解读程序码,看起来就好理解很多了… 权限类别解说 :manage, :all, ..etc.

cancan 里面用了一堆自定义缩写,如 :manage、:read、:update、:all,让人不是很了解在做什么。

  • :manage: 是指这个 controller 内所有的 action
  • :read : 指 :index 和 :show
  • :update: 指 :edit 和 :update
  • :destroy: 指 :destroy
  • :create: 指 :new 和 :crate

而 :all 是指所有 object (resource)

当然,不只是 CRUD 的 method 才可以被列上去,如果你有其他非 RESTful 的 method 如 :search,也是可以写上去..,只是要一条一条列上去,有点麻烦就是了。 组合技:alias_action

cancan 还提供了组合技,要是嫌原先的 :update, :read 这种组合包不够用。还可以用 alias_action 自己另外再组。例如把 :update 和 :destroy 组成 :modify。

alias_action :update, :destroy, :to => :modify
can :modify, Comment

组合技: 定制method

要是你嫌每个角色都要一条一条把权限列上去,超麻烦。可以把一些共通的权限包成 method。用叠加 method 上去的方式列举。比如把基础权限都包成 basic_read_only、account_manager_only, etc…

def basic_read_only
  can :read,    Topic
  can :list,    Topic
  can :search,  Topic
end

针对物件状态控管

在 User story 中,使用者固然 can :update, Topic,但还是让人觉得觉得哪里有点怪怪的?

是的。使用者应该只能编辑和修改属于自己的文章,can :update, Topic 只有说使用者可以「修改文章」啊(等于可以修改所有文章) XD

所以 ability.rb 就要这样设计了

can :update, Topic do |topic|
  (topic.user_id == user.id)
end

can :destroy, Topic do |topic|
   (topic.user_id == user.id)
end

可以玩的更加进阶:

can :publish, Post do |post|
  ( post.draft? || post.submitted? ) && !post.published?
end

其他

cancan 还有其他进阶主题可以继续探讨,读者可以自行研究:

  • Nested Resources
  • Exception Handling
  • Ensure Authorization

不过关于「难懂」和「难用」的部分,我想我应该讲的差不多了…

小结

在写这一系列文章时,我发现 cancan 的作者,其实把大部分的文件与范例,都写在 lib/ 下的 RDOC 里面了,光看 code comment 其实就可以了解大半流程。

不过我觉得 cancan 让人觉得难读的最大原因,可能还是官方缺乏一个 example ability.rb,对于被隐藏的自动完成部分也缺乏解释,所以才造成大家觉得 cancan 是个难用的 magic library。事实上如果你开始搞懂 cancan 怎么撰写的话,它是可以帮你把网站的权限 code 处理的非常漂亮又易懂的。

后记

又积累了一项能力,cancan相关的。




傲娇的使用Disqus