转载

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

前言

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

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

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

澄清权限管理的几个概念

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

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

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

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

资源与控制器之间的关系: 资源与控制器是一对多的关系, 即每一个控制器的 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. 形成一个真正的可动态创建角色与资源的系统:

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

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

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

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)构成了唯一的键.

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

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

核心实现

如何实现存储 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 类型的项目, 则还需要考虑 用户组资源组 的概念. 本文就不多说了.

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

正文到此结束
Loading...