Rails Rails 利用 cancan 实现一个优雅可扩展的角色管理系统

lyfi2003 · 2015年05月10日 · 最后由 pynix 回复于 2017年05月12日 · 11609 次阅读
本帖已被管理员设置为精华贴

前言

我们在开发 web app 的时候,非常常见的一个需求便是:

如何实现一个角色管理系统,可以自由创建新角色,每个角色可以关联许多资源 ( 权限 ), 并通过角色系统控制系统的访问权限。

本文即讲述 Rails 中如何优雅的,以最少的代码量,高可维护性来实现这个功能。

澄清权限管理的几个概念

用户管理系统:很多新手将用户管理与权限管理混在一起,实际上,用户管理系统只解决一个问题:用户如何被授权并登录系统的。比如 密码认证,USB key 认证,Oauth2 认证等。在 Rails 非常常用的 devise gem 正是解决这个问题的。

角色权限系统:权限管理有很多种实现,最为简单易用的即为基于角色的管理系统。即:

资源:被权限控制的对象称为资源,一般新手会把权限跟资源混在一起,这在实现系统的时候是大忌。

资源与控制器之间的关系:资源与控制器是一对多的关系,即每一个控制器的 action 可声明一个它使用的资源。而资源,则包括一个动作和一个对象 (对应于 Rails 的 model ).

cancan 会有一个相对 "智能" 的方式自动加载并验证权限,但我不建议使用,因为它破坏了这种对应关系,不仅声明了不必要的资源,还过多使用了魔法来使得代码难于理解。

基本思路 - 站在巨人的肩上

Cancancan 是 Rails 界著名的权限管理系统,以简单易用见长。例如:

权限声明

class Ability
  include CanCan::Ability

  def initialize(user)
    if user.has_role?(:admin)
      can :create, User
    end
  end
end

权限控制:

<% if can? :update, @article %>
  <%= link_to "Edit", edit_article_path(@article) %>
<% end %>
def show
  @article = Article.find(params[:id])
  authorize! :read, @article
end

非常简单,但唯一的问题就是,它没有解决自定义角色与资源的问题,必须写死 ability.rb 文件,这叫我们很尴尬。

所以基本的思路是:

  1. 创建一套角色管理的 CRUD, 并引入 rolify gem 来帮我们管理角色与用户的关联 ( 忽略 rolify 对资源的管理,个人以为它设计很差 )
  2. 引入新的一个 "表": resources, 称之为资源,再引入另一个表:role_resources, 用来关联 roles 与 resources.
  3. 形成一个真正的可动态创建角色与资源的系统:

更优雅解决动态定义资源的问题

上述解决思路非常好,但唯一有问题的是,资源如果是数据库层创建的,那该如何实现控制呢?例如:

authorize! :read, @article

像这样的权限控制,在数据库上如何存储字段。

还有更细粒的权限如何实现?例如:

有一个商品订单,有多个管理员,要认领后才能操作,也就是权限声明为:

can :close, Order do |order|
  order.allocated_by_admin == user
end

这个问题将 resources 存在数据库上就非常不方便。我们反其道而行之:

  1. 资源几乎定义好不会变化
  2. 资源只会增,不会减

这两个特点告诉我们,我们完全可以像 cancan 那样做一个资源声明文件,如下:

# 权限管理中的资源声明
class Resource
  include Resourcing
  group :order do

    resource :read, Order
    resource [ :approve, :decline, :freeze, :finish, :renew, :deposit_margin ], Order
  end

  group :staff do
    resource [ :read, :update, :destroy ], Staff do |admin, staff|
      # 总部的有更多的权限
      ( admin.branch_company_id.nil? or
        # 分部的必须相等
        admin.branch_company_id == staff.branch_company_id ) &&
          # 并且无法删除自己
          admin != staff
    end

    resource :create, Staff
  end

end

如此,就可以非常优雅地定义与声明资源了,并非常方便地集成在角色关联中。那么主键是什么,很明显,资源的动作 (verb) 与数据对象 (model) 构成了唯一的键。

于是真正完整的数据表设计如下:

核心实现

如何实现存储 resources, 我们可使用 Rails 提供的 concerns :

# in file: app/models/concerns/resourcing.rb
module Resourcing
  extend ActiveSupport::Concern

  included do
    @groups = []
    @current_group = nil
    @resources = []
  end

  module ClassMethods

    # 为每个 resource 添加一个 group, 方便管理
    def group(name, &block)
      @groups << name
      @groups.uniq!
      @current_group = name
      block.call
    end

    def resource(verb_or_verbs, object, &block)
      raise "Need define group first" if @current_group.nil?
      group = @current_group
      behavior = block
      if verb_or_verbs.kind_of?(Array)
        verb_or_verbs.each do |verb|
          add_resource(group, verb, object, behavior)
        end
      else
        add_resource(group, verb_or_verbs, object, behavior)
      end
    end

    def add_resource(group, verb, object, behavior)
      name = "#{verb}_#{object.to_s.underscore}"
      resource = {
        name: name,
        group: group,
        verb: verb,
        object: object,
        behavior: behavior,
      }

      # TODO: check collision and uniqness here
      @resources << resource
    end

    def each_group(&block)
      @groups.each do |group|
        block.call(group)
      end
    end

    def each_resources_by(group, &block)
      resources = @resources.find_all { |r| r[:group] == group }
      resources.each(&block)
    end

    def find_by_name(name)
      resource = @resources.find { |r| r[:name] == name }
      raise "not found resource by name: #{name}" if resource.nil?
      resource
    end

  end
end

如何与 cancan 关联:

# in file: app/models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    # Define abilities for the passed in user here. For example:
    #
    user ||= Staff.new # guest user (not logged in)

    if user.has_role?(:admin)
      can :manage, :all
    end

    # 去掉 admin role
    Role.all_without_reserved.each do |role|
      next unless user.has_role?(role.name)
      role.role_resources.each do |role_resource|
        resource = Resource.find_by_name(role_resource.resource_name)
        if resource[:behavior]
          block = resource[:behavior]
          can resource[:verb], resource[:object] do |object|
            block.call(user, object)
          end
        else
          can resource[:verb], resource[:object]
        end
      end
    end
  end
end

# in app/models/role.rb
# == Schema Information
#
# Table name: roles
#
#  id            :integer          not null, primary key
#  name          :string(255)
#  resource_id   :integer
#  resource_type :string(255)
#  created_at    :datetime
#  updated_at    :datetime
#

class Role < ActiveRecord::Base
  RESERVED = [ :admin, :guest ]
  has_and_belongs_to_many :staffs, :join_table => :staffs_roles

  has_many :role_resources, dependent: :destroy

  def self.all_without_reserved
    self.all.reject do |role|
      RESERVED.include?(role.name)
    end
  end

end
# in file: app/models/role_resource.rb
# == Schema Information
#
# Table name: role_resources
#
#  id            :integer          not null, primary key
#  role_id       :integer
#  resource_name :string(255)
#  created_at    :datetime
#  updated_at    :datetime
#

class RoleResource < ActiveRecord::Base
  validates_uniqueness_of :resource_name, scope: :role_id

  belongs_to :role
end

效果

通过以上的实现,我们就可以得到像 cancan 一样易用的权限控制接口 ( 不变 ), 又可非常容易地定制 role 与 resource, 非常的酷。

例如:

role = Role.create(name: 'staff')
role.role_resources << RoleResource.create(resource_name: 'update_order')

user = User.first
user.add_role(role)
puts user.can?(:update, Order)
# output will be true

总结

可以自由创建角色的权限控制十分常见,但如果想优雅地实现这个效果,实际上难度非常大。

这一篇可以算是抛个砖,实现了一个相对简单一些的需求。

如果你在做企业 ERP 类型的项目,则还需要考虑 用户组资源组 的概念。本文就不多说了。

也十分欢迎讨论相关主题。

来自 WinDy's Blog

给 12 个基本表设计的跪了

太好了,最近一个项目也要重新设计类似的功能,我会先尝试下这个方案。

这个如何解决在判断权限的过程中带来的额外的数据库查询? 一个页面我有 10 个地方需要匹配权限,我需要额外的查询 10 次数据库?

推荐大家看看 pundit,https://github.com/elabs/pundit

在项目里使用了 感觉是更优雅的实现

形成一个真正的可动态创建角色与资源的系统

@lyfi2003 对这点存在疑问,从你目前提供的代码来看,如果增加新的权限,还是需要在 Resource 里手动添加权限,而不是直接从数据库里读取权限。

#3 楼 @leopku 之前用过 pundit 设计过权限,当时是从 api 拿到资源,然后针对资源和角色设计权限。不过那时候 pundit 还不支持 symbol 资源,现在支持了,细节有待验证。

我也推荐 Pundit

#5 楼 @kikyous #8 楼 @imlcl 不觉得 pundit 跟我这个方案有啥冲突啊,我这里实现的是一个角色管理方案,而 pundit 主要是跟 cancan 对比的。

最终还需要支持自定义角色管理。

#6 楼 @kayakjiang resources 基本只会加,不会减,所以写在文件里修改更方便,也只能程序员去定义资源。

如果你想完全自主控制 ( 比如在 UI 上 ) 资源创建,可将其放在数据库,但还要仔细设计支持 scope 的资源约束。

#4 楼 @killyfreedom 这个方案跟 cancan 是一致的,无法解决 N+1 查询问题。但可以用 N+1 查询的解决方案去尝试处理下。

#12 楼 @flowerwrong 比如一个用户列表,每个用户都会检查是否有修改权限。这样,cancan 就只能一个个查询了。

#13 楼 @lyfi2003 建议做个 demo,带后台的

另外,不可以把 resources 数据库内容做成 cache,存成 yml 吗?

#11 楼 @lyfi2003 为什么不尝试放到 redis 里面?

Resource marshal 之后存到 Redis Role 每个 role 作为一个 reds 的 list, 存储对应 resources 的 ID

#15 楼 @killyfreedom 我觉得你没理解,在处理一些精细化权限 ( scope ) 时,必须查数据库,如何放得 redis?

@peter @killyfreedom 本质上 resources 已经存到内存了,比你两个说的存到 redis 与 yml 更好用 ( 支持 block 式的 scope 检查 )

@lyfi2003 1 和 cancan 相比,本人推荐 pundit。我推荐 pundit 的原因是 cancan 的设计并不优雅。当初我在做选择时,稍微研究了下 cancan 和 pundit,结果发现 cancan 的设计让我感觉畏惧,以致不敢尝试它,而 pundit 是我可以接受的。因为研究 cancan 不深入,所以在此不敢妄言。

2 您给出的图:User <=> Role <=> Resource,是否考虑过把 Role 拿掉呢? 即直接通过 User 对应到 Resource。能否拿掉 Role 是要看业务需要,不过很多情况下其实是可以拿掉 Role 的。

我们之所以想到用 Role 的原因有多方面的,其中一个方面是历史原因,因为很多系统里都存在 Role 这个东西;另外一个 就是很多 User 会共用一个 Role。不过深入想想,就可以发现其实很多时候,用户对 Resource 的需求变化太大了,到时候很可能需要建许多 Role,一个 User 对应许多 Role,最后把管理员也搞得乱乱的。

而把 Role 拿掉,最大的好处是更 Direct,系统设计得也更简单。

就我过去的经历而言,我是完成了 User <=> Role <=> Resource 的实现后,老板提醒我是否可以把 Role 拿掉。然后我一想觉得有道理,就顶住了所有其他反对者的压力把 Role 给去掉了。

仅仅是个人的浅见,欢迎批评。

#17 楼 @gazeldx 其实还有一个 user<->user resources<->resources

#17 楼 @gazeldx 其实还有一个 user<->user resources<->resources

#17 楼 @gazeldx 这跟需求有关,很多时候不用设计 roles. 直接基于资源来设计的系统适合于管理用户不多的情况。如果管理者很多,每一次创建管理者都要去选各种资源,这个就麻烦了,以后也无法统一调整权限。

本篇是讲基于角色的权限系统,所以就不多说不基于 roles 的权限设计了。

#17 楼 @gazeldx #20 楼 @lyfi2003

我的设计本来是没有 Role 的,但因为 Cancan 的可定制性,可以把多个 resource 包在一起变成一个 resource,其实就是 Role 了,只是 Role 写在文件里没那么灵活,但资源不多的情况下,还是很 kiss 的。

比如: https://github.com/ZPVIP/church/blob/master/app/models/ability.rb

def modify_service
  can :read, Calendar
  can :destroy, Calendar
  can :create, Calendar
  can :services_edit, Calendar
  can :add_name, Calendar
  can :update_name, Calendar
end

#22 楼 @peter 你这个设计还行,但

  1. 丢掉了支持细化权限的 scope 的情况。
  2. 把权限写死了,不够灵活。

对于很多情况,像你这样子就够了。不用基于角色。

楼主都不用 accessible_by 的吗? 列表中显示 可以看到的内容,难道数据库找出所有 一个一个判断?

#9 楼 @lyfi2003 呃,可能我用 cancan 用得不好……后来都不再用这货了,感觉 pundit 使用起来对权限控制更为直观得细致一点。题外话,现在是不是用 cancancan 了?

cancan 好像不更新了!pundit

赞!!

亚飞试试 pundit 更有前途哟

pundit, +1。cancan 不更新了

#26 楼 @davidzhu001 #29 楼 @wenger cancancan https://github.com/CanCanCommunity/cancancan/commits/develop

如果 cancancan 不更新了,我相信还会有 cancancancan

#33 楼 @seaify The Role 我看过一阵,自带后台比较爽 比较适合做复杂的需要定制的权限

用这个方案,为什么还要用 cancan,自己写个判定方法就行了吧

cancan 这东西我觉得有这些缺点: 1,load 资源和检查这里的代码非常不明确 2,和 strong parameters 整合有坑,现在估计解决了 3,过度设计,复杂

Pundit 相比有这许多好处 1, 整个设计非常简单,就是一个简单的 plain old ruby object,进去读代码也就那几行而已 2, 用起来很简单,代码清晰,不会一行代码背后有 10 行另外的事情 3, 没有 cancan 那种过度设计和复杂

另外,我建议没用过 cancan 的人先去用一下 pundit,更能感受 ruby 的简单和快乐

#26 楼 @davidzhu001 我的管理员表名不是 user 而是 admin,怎么把 pundit 默认找 current_user 改为 current_admin

看完这个我就去用 Pundit 了,果然有什么事情还是需要大家讨论下

谢谢推荐 Pundit 的,现在就去看

能不能使用 UNIX 权限系统来实现,把所有的东西都作为资源,对资源只有四种操作,创建,查看,修改,删除 所有的资源都有 ower, user,关联 role, role 定义对所有资源的权限. 这里有个问题,如果能自动的识别资源,如何把代码与行为关联起来

#40 楼 @lilijreey 是可行的,抽象的不够,用 Ruby 的话没必要去操作二进制的移位了。用 C 语言的话这样抽象非常合理。

#41 楼 @lyfi2003 还是得自己做过一遍才会对这个主题有一定的了解

这里不要写死,需要动态配置。。。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号