极狐 GitLab

DeclarativePolicy 框架

DeclarativePolicy 框架旨在协助执行策略检查,并方便 EE 的扩展。app/policies 中的 DSL 代码是 Ability.allowed? 用于检查是否允许对某个主体执行特定操作的内容。

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

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

有关命名和约定的信息,请参阅权限约定

管理权限规则#

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

条件#

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

ruby
1class FooPolicy < BasePolicy 2 condition(:is_public) do 3 # @subject 保证是 Foo 的一个实例 4 @subject.public? 5 end 6 7 # 实例方法也可以从条件中调用 8 condition(:thing) { check_thing } 9 10 def check_thing 11 # ... 12 end 13end

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

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

规则#

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

ruby
1class FooPolicy < BasePolicy 2 # ... 3 4 rule { is_public }.enable :read 5 rule { ~thing }.prevent :read 6 7 # 等价地, 8 rule { is_public }.policy do 9 enable :read 10 end 11 12 rule { ~thing }.policy do 13 prevent :read 14 end 15end

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

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

~&| 运算符是 DeclarativePolicy::Rule::Base 中被覆盖的方法。

不要在规则 DSL 中使用布尔运算符,如 &&||,因为规则块中的条件是对象,而不是布尔值。这同样适用于三元运算符(condition ? ... : ...)和 if 块。这些运算符不能被覆盖,因此通过自定义 cop 禁止使用。

分数、顺序和性能#

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

例如,要调试 IssuePolicy,你可以运行以下命令:

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

示例调试输出如下:

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

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

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

在这里你可以看到前四条规则针对哪个用户和主体被评估为 false。例如,在最后一行中,你可以看到规则被激活是因为用户 sidneyjonesProject/4 上具有报告者角色。

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

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

作用域#

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

ruby
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 选项:

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

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

当条件普遍为真时,你可以使用 :global 作用域:

ruby
condition(:earth_exists, scope: :global) { Planet::Earth.exists? }

有关作用域的更多信息,请参阅 gem 的缓存文档

如果你在条件实际使用来自用户和主体两者的数据(包括简单的匿名检查!)时使用了 :scope 选项,你的结果会以过于全局的作用域进行缓存,并导致缓存错误。

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

ruby
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?

委托#

委托是将来自另一个策略的规则包含在不同的主体上。例如:

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

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

覆盖#

当委托不合理时,策略可以选择退出委托的能力。

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

ruby
1class ParentPolicy < BasePolicy 2 condition(:speaks_spanish) { @subject.spoken_languages.include?(:es) } 3 condition(:has_license) { @subject.driving_license.present? } 4 condition(:is_employed) { @subject.is_employed? } 5 6 rule { speaks_spanish }.enable :read_spanish 7 rule { has_license }.enable :drive_car 8 rule { is_employed }.enable :earn_money 9 rule { ~is_employed }.prevent :earn_money 10end

如果子策略委托给父策略,某些值将不正确。你可能正确地推断孩子会说父母的语言,但你不能仅仅因为父母可以就推断孩子可以开车或赚钱。

你可以处理其中一些情况。例如,你可以在子策略中禁止开车:

ruby
class ChildPolicy < BasePolicy delegate { @subject.parent } rule { default }.prevent :drive_car end

赚钱更为复杂。由于父策略中的 prevent 调用,如果父母没有就业,那么父母和孩子都不能赚钱。即使在子策略中显式启用 :earn_money 也不会起作用。

删除 ParentPolicy 中的 prevent 调用也没有帮助,因为启用 :earn_money 的规则在父策略和子策略之间不同。

通过委托,子资源只能启用父策略未明确阻止的权限。在这种情况下,只有父母赚钱的孩子自己才能赚钱。然而,不赚钱的父母可能仍然想给孩子零花钱。

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

ruby
1class ChildPolicy < BasePolicy 2 delegate { @subject.parent } 3 4 overrides :earn_money 5 6 condition(:has_allowance) { @subject.has_allowance? } 7 8 rule { has_allowance }.enable :earn_money 9end

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

使用 overrides 的替代方案#

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

其他方法可以包括例如使用不同的能力名称。赚取收入和赚取零花钱在语义上是不同的,它们可以以不同的方式命名(在这种情况下可能是 earn_salaryearn_allowance)。这可能取决于调用站点的多态性。如果你知道我们总是使用 ParentChild 检查策略,那么我们可以选择适当的能力名称。如果调用站点是多态的,那么我们就不能这样做。

指定策略类#

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

ruby
1class Foo 2 3 def self.declarative_policy_class 4 'SomeOtherPolicy' 5 end 6end

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