Gem RoleCore —— 快速构建定制化的 RBAC 系统

jasl · July 16, 2017 · Last by aoner replied at November 11, 2019 · 4540 hits

项目地址:https://github.com/rails-engine/role_core (文档可能已经有更新,以 repo 内的为准)

RoleCore 是什么?

即使是基于 RBAC 的访问控制系统,根据项目的需要,以下考量也会影响到设计:用户可拥有单角色或者多角色?角色是否能够继承?角色是否涉及 Scope(一般出现在多租户应用中)?是否混合 ABAC(Attribute Based Access Control)?

如同和上一集的 FormCore ,RoleCore 并没有提供开箱即用的方案,仅提供了用于实现 RBAC 系统的最小公共需求,遵从了 Improving engine functionality 使其易于被扩展,以此帮助你快速构建恰好满足项目需要的访问控制系统。

RoleCore 也没有实现请求的访问控制,因为 CanCanCan、Pundit 已经经过社区多年的验证,包括其用法也是深入人心。访问控制是一个比较敏感的功能,实现这部分首先要增加很多的工作量,配套的要补充大量测试以证明其安全,此外,用户还要学习 RoleCore 提供的 Helper 和 使用方式。所以,不妨提供一种机制,能够让用户选择喜欢的访问控制库。

此外,他同样利用了“运行时构造模型类”(在我的 FormCore 的介绍文章中有介绍)的技巧,使得前端的工作量大大减少,也更容易做 i18n。

RoleCore 提供了:

Demo

Dummy 即为 demo,预览方式参考 Readme#demo,这里演示了一个简单的任务管理应用(Project has many Tasks),用户可以拥有多个权限,访问控制使用 CanCanCan。

下文也将以此作为演示。

使用(基于 RoleCore 的 dummy app)

安装参考 Readme#Installation

创建 RoleCore 的 initializer

运行 bin/rails g role_core:config 在项目的 config/initializers 建立 role_core.rb 用于编写关于 RoleCore 的配置。

编写用于接入访问控制 Gem 的权限描述类

以 CanCanCan 为例,如果你需要和 CanCanCan 集成,以下代码可以直接复制。

当然更好的办法是制作一个 role_core-cancancan gem,这样这一步(也是最难理解的一步)就可以省略了。

RoleCore 提供的 Permission 类 只包含权限的标识(name)和优先级(priority)。

CanCanCan 的 Ability 的 DSL 形如:

user.can :create, Project
user.can :read, Project, is_public: true
user.can :update, Project do |project|
  project.user_id == user.id
end

为了能够集成进 CanCanCan,需要继承 RoleCore::Permission 增加 modelblock 部分。

app/lib(强烈建议建立这个目录,并用它存放 Rails 初始化后才需要的库的代码,可以享受到线程安全的 autoload 和 修改后无需重启应用的好处)或 lib 下建立 can_can_can_permission.rb,代码如下:

class CanCanCanPermission < RoleCore::Permission
  # CanCanCan 的 can 方法的形参 action(即第一个形参)未必和权限的标识一致
  # 考虑 编辑任务 和 编辑我的任务 这种情况
  attr_reader :action, :options

  # 父类定义的构造方法签名
  def initialize(name, priority: 0, **options, &block)
    super # 调用父类的构造方法

    @model = options.fetch(:model) # 从 options 中提取模型信息
    @action = options.fetch(:action) { name } # 若没有提供 action,则使用 name 作为 CanCanCan 需要的 action
    @options = options.except(:model, :action)
    @block = block # 储存传入的 block
  end

  # 基类的签名为 call(context, *) 故除了第一个形参 context 外,可以根据需要增加新的形参
  # context 为 CanCanCan 的 Ability 的实例
  # user 为 Ability 的构造方法的形参 user,即当前用户
  def call(context, user)
    if block_attached?
      # 因为 user 需要在每个请求处理时动态传入(即 call 的同名形参),所以我们要让 block 的第一个形参为 user 以方便注入
      # 故预期的 block 形如:{ |user, project| project.user_id == user.id }
      # 但我们需要 block 拥有 user 的信息以便做判断,但 CanCanCan 的 DSL 里不可以包含这个 user,所以要消去它
      # 于是这里使用了一个 Proc 的技巧 —— 科里化
      context.can @action, @model, &@block.curry[user]
    else
      context.can @action, @model, @options
    end
  end

  def block_attached?
    !!@block
  end
end

然后在 config/initializers/role_core.rb 里指定刚编写的 Permission 类

RoleCore.permission_class = CanCanCanPermission

声明项目所包含的权限

config/initializers/role_core.rb 里编写

RoleCore.permission_set_class.draw do
  # group 除了第一个实参外(这里即 `:project` 都将附加到其下的 `permission` 方法调用里)
  group :project, model: Project do
    # 结合 `group` 等价于 `permission :destroy, model: Project`
    permission :destroy
    permission :create, default: true # default 用于声明是否默认拥有该权限 
    permission :update
    # 调高优先级,用户拥有这一项权限的时候,保证排序后总在下一个
    # CanCanCan 的特性是,相同的 action,后声明的覆盖先前的
    # 参考 https://github.com/CanCanCommunity/cancancan/wiki/Ability-Precedence
    permission :read, priority: 1, default: true
    # 指定 action 为 :read, 这句将被翻译为 CanCanCan 的 DSL `can :read, Project, is_public: true`
    permission :read_public, action: :read, is_public: true
  end

  group :task, model: Task do
    permission :create, default: true
    permission :destroy
    permission :update, priority: 1

    permission :update_my_own, action: :update, default: true do |user, task|
      task.user_id == user.id
    end
  end
end.finalize! # finalize! 可选,意思是冻结权限声明

将 Role 和项目的“用户”挂钩

这里演示的是 User 可以有多个 Role 的情况,即多对多关系。

需要增加关联模型(迁移省略)

class RoleAssignment < ApplicationRecord
  belongs_to :user
  belongs_to :role

  validates :role,
            uniqueness: {scope: :user}
end

RoleCore::Role 已经做了 STI 支持,故可以在项目端实现 Role 模型,扩展新的行为

class Role < RoleCore::Role
  has_many :role_assignments, dependent: :destroy
  has_many :users, through: :role_assignments
end

用户(User)端代码省略

在 CanCanCan 的 Ability 中释放我们刚做的的权限定义

由于我们是用户可以对应多个角色,故需要先合并所有角色的权限,可以在 User 模型中编写如下方法

def permitted_permissions
  # role 的 permitted_permissions 方法返回去过重的排过序的 CanCanCanPermission 对象
  # 这里我们得到所有角色的 permitted_permissions 后重新做去重和排序,才能保证结果正确
  roles.map(&:permitted_permissions).flatten.uniq.sort_by(&:priority)
end

CanCanCan 的 app/models/ability.rb 中只需要编写两行即可

class Ability
  include CanCan::Ability

  def initialize(user)
    # See the wiki for details:
    # https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities

    # 遍历我们去重、排序过的用户拥有的权限集合
    user.permitted_permissions.each do |permission|
      # 调用我们之前实现的 call 方法
      permission.call(self, user)
    end
  end
end

角色的权限管理界面

略,参见 https://github.com/rails-engine/role_core/blob/master/test/dummy/app/views/roles/_permissions.html.erb

Demo 使用了 Bulma CSS 框架,仅需 22 行即可达到如图效果:

i18n

略,参见 https://github.com/rails-engine/role_core/blob/master/test/dummy/config/locales/role_core.en.yml

现实中真没必要太复杂的权限控制,太复杂了连权限配置都是个门槛。。

Reply to pynix

绝大多数系统都不用,复杂的权限控制主要受众是企业系统,其实我做的一系列 xxxCore 都是在探索这个领域。

Reply to jasl

👏 好吧

Reply to pynix

另外源码上,整个实现一百来行(当然也是因为确实做得事情少),并没有使用“魔法”,通过动态生成模型类可以极大程度复用 Rails 自身提供的机制,从而大幅减少工作量,尤其是需要和前端交互的部分(其实就是在指模型)

这里我实验性的写了一个 options_model 库来实现动态生成模型,但其实这个也不是必要的,用 Virtus ActiveType 都可以

@jasl 这个地方参数要怎么设置,手动一个一个指定吗?

# Only allow a trusted parameter "white list" through.
  private def role_params
    params.require(:role).permit(:name, permissions_attributes: {})
  end
Reply to aoner

就这样写,这样写的意思是允许 permissions_attributes 里的任何内容,这个跟请求传入的数据的结构是一样的

@jasl 这样编辑权限的时候好像没生效

Parameters: {"utf8"=>"✓", "authenticity_token"=>"w0o9CGy+oR9bqZaPpmglcNnKrzRS+e7/LFTZOPePAOWrHHwbuMABqzolmhB3u0vVUSRSPqs77BwKHKU8iObC+Q==", "role"=>{"name"=>"j", "permissions_attributes"=>{"task"=>{"create"=>"1", "destroy"=>"0", "update"=>"0", "update_my_own"=>"1", "destroy_my_own"=>"1"}, "production"=>{"create"=>"1", "destroy"=>"0", "accept"=>"0", "read"=>"1", "read_public"=>"0"}, "topic"=>{"create"=>"1", "destroy"=>"1", "accept"=>"1"}}}, "commit"=>"Update Role", "id"=>"7"}
  Role Load (0.5ms)  SELECT  `roles`.* FROM `roles` WHERE `roles`.`type` IN ('Role') AND `roles`.`id` = 7 LIMIT 1
Query Trace:
      app/controllers/roles_controller.rb:47:in `set_role'
Unpermitted parameters: task, production, topic
   (0.9ms)  BEGIN
  SQL (2.0ms)  UPDATE `roles` SET `permissions` = NULL, `updated_at` = '2019-11-11 16:52:40' WHERE `roles`.`id` = 7
Query Trace:
      app/controllers/roles_controller.rb:32:in `update'
   (2.8ms)  COMMIT
Reply to aoner

我那个 Git 的 dummy 应用么?

Unpermitted parameters: task, production, topic 问题出在这了,我看看

Reply to aoner

好像是我正贴的网址错了... 后来听了华顺的建议 重构并放到 https://github.com/rails-engine/role_core 去了... 你试试...

@jasl 照着你的那个例子自己写的

Reply to aoner

贴一下你 控制器 action 的代码?

params.require(:role).permit(:name, permissions_attributes: {}) 这样 accepts_nested_attributes 的写法是 Rails 的标准做法

https://guides.rubyonrails.org/action_controller_overview.html#permitted-scalar-values

之前没人提过这个问题也,我不太觉得这里能有问题

Reply to aoner

这个帖子一直没有更新,虽然基本都没有变(大体只改了名字)不过可以看下 https://github.com/rails-engine/role_core 我忘记是不是有更新了,这 gem 我自己也在用,所以你提的这个问题有点奇怪

@jasl 用的是 role_core,和你 dummy 唯一不同的地方就是我没用 cancancan

def create
    @role = Role.new(role_params)

    if @role.save
      redirect_to roles_url, notice: "Role was successfully created."
    else
      render :new
    end
  end
# Only allow a trusted parameter "white list" through.
  private def role_params
    params.require(:role).permit(:name, permissions_attributes: {})
  end
Reply to aoner
➜  role_core git:(master) rails c
Loading development environment (Rails 6.0.0)
[1] pry(main)> pa = {"utf8"=>"✓", "authenticity_token"=>"w0o9CGy+oR9bqZaPpmglcNnKrzRS+e7/LFTZOPePAOWrHHwbuMABqzolmhB3u0vVUSRSPqs77BwKHKU8iObC+Q==", "role"=>{"name"=>"j", "permissions_attributes"=>{"task"=>{"create"=>"1", "destroy"=>"0", "update"=>"0", "update_my_own"=>"1", "destroy_my_own"=>"1"}, "production"=>{"create"=>"1", "destroy"=>"0", "accept"=>"0", "read"=>"1", "read_public"=>"0"}, "topic"=>{"create"=>"1", "destroy"=>"1", "accept"=>"1"}}}, "commit"=>"Update Role", "id"=>"7"}
=> {"utf8"=>"✓",
"authenticity_token"=>"w0o9CGy+oR9bqZaPpmglcNnKrzRS+e7/LFTZOPePAOWrHHwbuMABqzolmhB3u0vVUSRSPqs77BwKHKU8iObC+Q==",
 "role"=>
  {"name"=>"j",
   "permissions_attributes"=>
    {"task"=>{"create"=>"1", "destroy"=>"0", "update"=>"0", "update_my_own"=>"1", "destroy_my_own"=>"1"},
     "production"=>{"create"=>"1", "destroy"=>"0", "accept"=>"0", "read"=>"1", "read_public"=>"0"},
     "topic"=>{"create"=>"1", "destroy"=>"1", "accept"=>"1"}}},
 "commit"=>"Update Role",
 "id"=>"7"}

[2] pry(main)> params = ActionController::Parameters.new(pa)
=> <ActionController::Parameters {"utf8"=>"✓", "authenticity_token"=>"w0o9CGy+oR9bqZaPpmglcNnKrzRS+e7/LFTZOPePAOWrHHwbuMABqzolmhB3u0vVUSRSPqs77BwKHKU8iObC+Q==", "role"=>{"name"=>"j", "permissions_attributes"=>{"task"=>{"create"=>"1", "destroy"=>"0", "update"=>"0", "update_my_own"=>"1", "destroy_my_own"=>"1"}, "production"=>{"create"=>"1", "destroy"=>"0", "accept"=>"0", "read"=>"1", "read_public"=>"0"}, "topic"=>{"create"=>"1", "destroy"=>"1", "accept"=>"1"}}}, "commit"=>"Update Role", "id"=>"7"} permitted: false>

[3] pry(main)> params.require(:role).permit(:name, permissions_attributes: {})
=> <ActionController::Parameters {"name"=>"j", "permissions_attributes"=><ActionController::Parameters {"task"=><ActionController::Parameters {"create"=>"1", "destroy"=>"0", "update"=>"0", "update_my_own"=>"1", "destroy_my_own"=>"1"} permitted: true>, "production"=><ActionController::Parameters {"create"=>"1", "destroy"=>"0", "accept"=>"0", "read"=>"1", "read_public"=>"0"} permitted: true>, "topic"=><ActionController::Parameters {"create"=>"1", "destroy"=>"1", "accept"=>"1"} permitted: true>} permitted: true>} permitted: true>

[4] pry(main)> params.require(:role).permit(:name, permissions_attributes: {}).to_h
=> {"name"=>"j",
 "permissions_attributes"=>
  {"task"=>{"create"=>"1", "destroy"=>"0", "update"=>"0", "update_my_own"=>"1", "destroy_my_own"=>"1"},
   "production"=>{"create"=>"1", "destroy"=>"0", "accept"=>"0", "read"=>"1", "read_public"=>"0"},
   "topic"=>{"create"=>"1", "destroy"=>"1", "accept"=>"1"}}}

[5] pry(main)> Role.new(params.require(:role).permit(:name, permissions_attributes: {}))
   (0.8ms)  SELECT sqlite_version(*)
=> #<Role:0x0000564b77c637e8
 id: nil,
 name: "j",
 permissions:
  #<Global:OptionsModel {:foo=>false, :bar=>false, :project=>{:create=>true, :destroy=>false, :update=>false, :read=>true, :read_public=>false, :task=>{:create=>true, :destroy=>false, :update=>false, :update_my_own=>true, :destroy_my_own=>true}}, :task=>{:read=>false, :create=>true, :destroy=>false, :update=>false}}>,
 type: "Role",
 created_at: nil,
 updated_at: nil>

我在控制台根据你提供的日志模拟过滤请求的动作,还是复现不出来

你的 Ruby 和 Rails 版本是多少呢?我应该在 5.2 和 6.0 都验证过的,你那边用我的 Dummy app 有问题么?

Reply to aoner

那我晚上到家 起一个裸的试试...不过我自己不用 cancancan 也,理论上也跟他无关

@jasl 我的 ruby 版本是 2.5.1 rails5.0.7.2

Reply to aoner

哦...那破案了...这个写法 5.1 才有的... https://github.com/rails/rails/commit/e86524c0c5a26ceec92895c830d1355ae47a7034

遗留项目用 https://github.com/rails/rails/issues/9454#issuecomment-310821406 这种写法,RoleCore 应该整体还是兼容 5.0 的

新项目直接上 6.0 吧,或者至少 5.1 以上

@jasl 嗯,我刚也试了,在 5.2 下正常,感谢🍻

You need to Sign in before reply, if you don't have an account, please Sign up first.