Help us learn about your current experience with the documentation. Take the survey.

框架 DeclarativePolicy

DeclarativePolicy 框架旨在协助执行策略检查,并为 EE(企业版)提供易于扩展的能力。app/policies 中的 DSL 代码是 Ability.allowed? 用来检查某个特定操作是否允许作用于主体的代码。

所使用的策略基于主体的类名 - 因此 Ability.allowed?(user, :some_ability, project) 会创建一个 ProjectPolicy 并检查其中的权限。

Ruby gem 源码可在 declarative-policy GitLab 项目中获取。

有关命名和约定的信息,请参见 permission conventions

管理权限规则

权限分为两部分:conditions(条件)和 rules(规则)。条件是可以访问数据库和环境的布尔表达式,而规则是表达式和其他规则的静态组合,用于启用或阻止某些能力。一个能力要被允许,必须至少被一个规则启用,并且不被任何规则阻止。

条件

条件通过 condition 方法定义,并赋予一个名称和一个代码块。该代码块在策略对象的上下文中执行 - 因此它可以访问 @user@subject,以及调用策略上定义的任何方法。@user 可能为 nil(在匿名情况下),但 @subject 保证是主体类的真实实例。

class FooPolicy < BasePolicy
  condition(:is_public) do
    # @subject guaranteed to be an instance of Foo
    @subject.public?
  end

  # instance methods can be called from the condition as well
  condition(:thing) { check_thing }

  def check_thing
    # ...
  end
end

当你定义一个条件时,会在策略上定义一个谓词方法来检查该条件是否通过 - 因此在上面的例子中,FooPolicy 的实例也会响应 #is_public?#thing?

条件根据其作用域进行缓存。作用域和排序将在后面介绍。

规则

rule(规则)是条件和其它规则的逻辑组合,配置用于启用或阻止某些能力。规则配置是静态的 - 规则的逻辑不能访问数据库或了解 @user@subject。这使我们能够在条件级别进行缓存。规则通过 rule 方法指定,该方法接受一个 DSL 配置块,并返回一个响应 #enable#prevent 的对象:

class FooPolicy < BasePolicy
  # ...

  rule { is_public }.enable :read
  rule { ~thing }.prevent :read

  # equivalently,
  rule { is_public }.policy do
    enable :read
  end

  rule { ~thing }.policy do
    prevent :read
  end
end

在规则 DSL 中,你可以使用:

  • 普通单词按名称提及条件 - 当该条件为真时生效的规则。
  • ~ 表示否定,也可用 negate
  • &| 是逻辑组合,也可用 all?(...)any?(...)
  • can?(:other_ability) 委托给适用于 :other_ability 的规则。这与实例方法 can? 不同,后者可以动态检查 - 这只是配置了对另一个能力的委托。

~&| 运算符是在 DeclarativePolicy::Rule::Base 中被重写的方法。

不要在规则 DSL 中使用布尔运算符如 &&||,因为规则块中的条件是对象,而不是布尔值。三元运算符(condition ? ... : ...)和 if 块也是如此。这些运算符无法被重写,因此通过 custom cop 被禁止。

分数、顺序和性能

要查看规则如何被评估为判断,打开 Rails 控制台并运行:policy.debug(:some_ability)。这将按评估顺序打印规则。

例如,如果你想调试 IssuePolicy。你可以这样运行调试器:

user = User.find_by(username: 'john')
issue = Issue.first
policy = IssuePolicy.new(user, issue)
policy.debug(:read_issue)

示例调试输出如下所示:

- [0] prevent when all?(confidential, ~can_read_confidential) ((@john : Issue/1))
- [0] prevent when archived ((@john : Project/4))
- [0] prevent when issues_disabled ((@john : Project/4))
- [0] prevent when all?(anonymous, ~public_project) ((@john : Project/4))
+ [32] enable when can?(:reporter_access) ((@john : Project/4))

每行代表一个被评估的规则。有几点需要注意:

  1. - 符号表示规则块被评估为 false+ 符号表示规则块被评估为 true
  2. 括号内的数字表示分数。
  3. 行的最后一部分(例如 @john : Issue/1)显示该规则的用户名和主体。

在这里你可以看到前四个规则对于该用户和主体被评估为 false。例如,你可以在最后一行看到该规则被激活,因为用户 johnProject/4 上具有 Reporter 角色。

当询问策略某个特定能力是否被允许(policy.allowed?(:some_ability))时,它不一定需要计算策略上的所有条件。首先,只选择与该特定能力相关的规则。然后,执行模型利用短路特性,尝试根据计算成本的启发式方法对规则进行排序。这种排序是动态且缓存感知的,因此在计算其他条件之前,会优先考虑已计算的条件。

分数由开发者通过 condition 中的 score: 参数选择,以表示评估此规则相对于其他规则的成本。

作用域

有时,一个条件只使用 @user 或只使用 @subject 的数据。在这种情况下,我们想要改变缓存的作用域,以便不必要地重新计算条件。例如,给定:

class FooPolicy < BasePolicy
  condition(:expensive_condition) { @subject.expensive_query? }

  rule { expensive_condition }.enable :some_ability
end

简单来说,如果我们调用 Ability.allowed?(user1, :some_ability, foo)Ability.allowed?(user2, :some_ability, foo),我们将不得不计算两次条件 - 因为它们针对不同的用户。但如果我们使用 scope: :subject 选项:

  condition(:expensive_condition, scope: :subject) { @subject.expensive_query? }

那么条件的结果仅基于主体进行全局缓存 - 因此不会为不同的用户重复计算。类似地,scope: :user 仅基于用户进行缓存。

危险:如果条件实际上同时使用了用户和主体的数据(包括简单的匿名检查!),而你使用了 :scope 选项,你的结果会在过于全局的作用域中被缓存,导致缓存错误。

有时我们为一个主体检查很多用户的权限,或为一个用户检查很多主体的权限。在这种情况下,我们想要设置一个首选作用域 - 即告诉系统我们倾向于可以在重复参数上缓存的规则。例如,在 Ability.users_that_can_read_project 中:

def users_that_can_read_project(users, project)
  DeclarativePolicy.subject_scope do
    users.select { |u| allowed?(u, :read_project, project) }
  end
end

例如,这优先检查 project.public? 而不是 user.admin?

委托

委托是包含来自另一个策略的规则,作用于不同的主体。例如:

class FooPolicy < BasePolicy
  delegate { @subject.project }
end

包含来自 ProjectPolicy 的所有规则。委托的条件使用正确的委托主体进行评估,并与策略中的常规规则一起排序。实际上只考虑与特定能力相关的规则。

覆盖

我们允许策略选择不使用委托的能力。

委托的策略可能以对委托策略不正确的方式定义某些能力。例如,在父子关系中,某些能力可以推断,而某些不能:

class ParentPolicy < BasePolicy
  condition(:speaks_spanish) { @subject.spoken_languages.include?(:es) }
  condition(:has_license) { @subject.driving_license.present? }
  condition(:enjoys_broccoli) { @subject.enjoyment_of(:broccoli) > 0 }

  rule { speaks_spanish }.enable :read_spanish
  rule { has_license }.enable :drive_car
  rule { enjoys_broccoli }.enable :eat_broccoli
  rule { ~enjoys_broccoli }.prevent :eat_broccoli
end

在这里,如果我们将子策略委托给父策略,某些值将不正确 - 我们可能正确推断出孩子可以说父母语言,但错误地推断出孩子可以开车或吃西兰花仅仅因为父母可以并且这样做。

其中一些我们可以处理 - 例如,我们可以在子策略中普遍禁止开车:

class ChildPolicy < BasePolicy
  delegate { @subject.parent }

  rule { default }.prevent :drive_car
end

但食物偏好的那个更难 - 由于父策略中的 prevent 调用,如果父母不喜欢它,即使在子策略中调用 enable 也不能启用 :eat_broccoli

我们可以移除父策略中的 prevent 调用,但这仍然对我们没有帮助,因为规则不同:父母可以吃他们喜欢的东西,而孩子吃被给的东西,前提是他们表现良好。允许委托最终只会让父母喜欢绿色蔬菜的孩子吃它。但父母可能会给孩子西兰花,即使他们自己不喜欢,因为它对孩子的健康有益。

解决方案是在子策略中覆盖 :eat_broccoli 能力:

class ChildPolicy < BasePolicy
  delegate { @subject.parent }

  overrides :eat_broccoli

  condition(:good_kid) { @subject.behavior_level >= Child::GOOD }

  rule { good_kid }.enable :eat_broccoli
end

通过这个定义,ChildPolicy 永远不会在 ParentPolicy 中查找来满足 :eat_broccoli,但它会将其用于任何其他能力。然后子策略可以以对 Child 有意义而不是对 Parent 有意义的方式定义 :eat_broccoli

使用 overrides 的替代方案

覆盖策略委托是复杂的,原因与委托本身复杂相同 - 它涉及逻辑推理的思考,并且需要明确语义。滥用 override 可能导致代码重复,并可能引入安全漏洞,允许应该被阻止的事情发生。因此,它只应在其他方法不可行时使用。

其他方法可以包括使用不同的能力名称。选择吃食物和吃被给的食物在语义上是不同的,它们可以有不同的名称(在这个例子中可能是 chooses_to_eat_broccolieats_what_is_given)。这取决于调用站点的多态性。如果你知道我们总是用 ParentChild 检查策略,那么我们可以选择适当的能力名称。如果调用站点是多态的,那么我们就不能这样做。

指定策略类

你也可以覆盖用于给定主体的策略:

class Foo

  def self.declarative_policy_class
    'SomeOtherPolicy'
  end
end

这使用并检查 SomeOtherPolicy 类上的权限,而不是通常计算的 FooPolicy 类。