DeclarativePolicy フレームワーク

DeclarativePolicyフレームワークは、ポリシー・チェックのパフォーマンスを支援し、EEの拡張を容易にするために設計されています。app/policies の DSL コードは、Ability.allowed? が、特定のアクションがサブジェクトで許可されているかどうかをチェックするために使用するものです。

使用されるポリシーは、サブジェクトのクラス名に基づいています。したがって、Ability.allowed?(user, :some_ability, project) は、ProjectPolicy を作成し、その権限をチェックします。

Ruby gemのソースはdeclarative-policyGitLabプロジェクトにあります。

権限ルールの管理

権限はconditionsrules の2つの部分に分けられます。Conditionsはデータベースと環境にアクセスできるブーリアン式で、Rulesは特定の能力を有効または無効にする式と他のルールの静的に設定された組み合わせです。ある能力が許可されるためには、少なくとも1つのルールによって有効化され、どのルールによっても阻止されない必要があります。

条件

条件は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内では、以下を使用することができます:

  • 規則 DSL の中では、次のようなものを使うことができます: 条件を指定する通常の単語 - 条件が真であるときに有効になる規則。
  • ~ は否定を表し、negate もあります。
  • &| は論理の組み合わせで、all?(...)any?(...) としても利用できます。
  • can?(:other_ability) :other_ability これは、動的にチェックできるインスタンスメソッドcan? とは異なります。

~ & および| オペレータは、DeclarativePolicy::Rule::Baseでオーバーライドされたメソッドです。

ルールブロック内の条件はオブジェクトであり、ブーリアンではないため、&&|| のようなブーリアン演算子をルールDSL内部で使用しないでください。三項演算子 (condition ? ... : ...) やif ブロックも同様です。これらのオペレーションはオーバーライドできないため、カスタム・コップで禁止されています。

スコア、順序、パフォーマンス

ルールがどのように評価されて判定になるかを見るには、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 )は、そのルールのユーザー名と件名を示します。

ここでは、最初の4つのルールが、どのユーザとサブジェクトに対してfalse 評価されたかがわかります。例えば、最後の行では、ユーザjohnProject/4 のレポーター・ロールを持っていたため、ルールがアクティビティされたことがわかります。

ポリシーが特定の能力を許可するかどうか尋ねられるとき (policy.allowed?(:some_ability))、必ずしもポリシーのすべての条件を計算する必要はありません。まず、その特定の能力に関連するルールだけが選択されます。次に、実行モデルは短絡を利用し、計算コストがどの程度高いかをヒューリスティックに判断してルールを並べ替えようとします。並べ替えは動的でキャッシュを意識して行われるため、他の条件を計算する前に、以前に計算された条件が最初に考慮されます。

スコアは、開発者がconditionscore: パラメータで選択します。これは、このルールを評価することが、他のルールと比較してどれだけコストがかかるかを示すものです。

スコープ

時には、@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) を呼び出した場合、条件を2回計算しなければなりません。しかし、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

これは例えば、user.admin? をチェックするよりも、project.public? をチェックすることを優先します。

委任

委任とは、別のポリシーから、別の主題に関するルールを含めることです。例えば

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:eat_broccoli参照する:eat_broccoliことは :eat_broccoliありません。:eat_broccoli子ポリシーは :eat_broccoli ParentではなくChild に対して意味のある方法で:eat_broccoli定義することができます :eat_broccoli

を使用する代替方法overrides

ポリシーの委譲を上書きすることは、委譲が複雑であるのと同じ理由で、複雑です - 論理的な推論を推論し、セマンティクスを明確にする必要があります。override を誤用すると、コードが重複したり、セキュリティバグが発生する可能性があります。このような理由から、他のアプローチが実行不可能な場合にのみ使用されるべきです。

他のアプローチとしては、例えば、異なる能力名を使うことなどが考えられます。食べ物を食べることを選択することと、与えられた食べ物を食べることは、意味的に異なるものであり、異なる名前にすることができます(この場合、おそらくchooses_to_eat_broccolieats_what_is_given )。呼び出し先がどの程度多型であるかにもよります。常にParent またはChildでポリシーをチェックすることがわかっていれば、適切な能力名を選択できます。呼び出し先が多形である場合、そのようなことはできません。

ポリシー・クラスの指定

指定したサブジェクトに使用する Policy をオーバーライドすることもできます:

class Foo

  def self.declarative_policy_class
    'SomeOtherPolicy'
  end
end

これは、通常の計算されたFooPolicy クラスではなく、SomeOtherPolicy クラスの権限を使用し、チェックします。