我们在开发 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
文件,这叫我们很尴尬。
所以基本的思路是:
上述解决思路非常好,但唯一有问题的是,资源如果是数据库层创建的,那该如何实现控制呢?例如:
authorize! :read, @article
像这样的权限控制,在数据库上如何存储字段。
还有更细粒的权限如何实现?例如:
有一个商品订单,有多个管理员,要认领后才能操作,也就是权限声明为:
can :close, Order do |order|
order.allocated_by_admin == user
end
这个问题将 resources 存在数据库上就非常不方便。我们反其道而行之:
这两个特点告诉我们,我们完全可以像 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