DeclarativePolicy 프레임워크

DeclarativePolicy 프레임워크는 정책 확인의 성능을 지원하고 EE의 확장을 용이하게 하는 것을 목표로 합니다. app/policies의 DSL 코드는 Ability.allowed?가 주체에서 특정 동작을 허용할 수 있는지 확인하는 데 사용됩니다.

사용된 정책은 주제의 클래스 이름을 기반으로 합니다 - 따라서 Ability.allowed?(user, :some_ability, project)ProjectPolicy를 생성하고 해당 권한을 확인합니다.

Ruby gem 소스는 declarative-policy GitLab 프로젝트에서 이용할 수 있습니다.

네이밍과 규약에 대한 자세한 내용은 Permission conventions page를 참조하세요.

권한 규칙 관리

권한은 conditionsrules로 나뉩니다. Conditions은 데이터베이스와 환경에 접근할 수 있는 부울식 표현식이며, rules는 정적으로 구성된 다양한 표현식 및 다른 규칙의 조합으로 특정한 능력을 활성화하거나 방지합니다. 허용된 능력은 최소한 하나의 규칙에 의해 활성화되어야 하며, 어떤 것에 의해서도 방해받아서는 안 됩니다.

Conditions

Conditions은 condition 메소드에 의해 정의되고, 이름과 블록이 주어집니다. 블록은 정책 객체의 컨텍스트에서 실행되므로 @user@subject에 액세스할 수 있을 뿐만 아니라 정책에서 정의된 모든 메소드도 호출할 수 있습니다. @user는 nil일 수 있지만 @subject는 주제 클래스의 실제 인스턴스임이 보장됩니다.

class FooPolicy < BasePolicy
  condition(:is_public) do
    # @subject는 Foo의 인스턴스임이 보장됩니다
    @subject.public?
  end
  
  # 인스턴스 메소드도 condition에서 호출할 수 있습니다
  condition(:thing) { check_thing }
  
  def check_thing
    # ...
  end
end

Condition을 정의할 때, 해당 condition이 통과하는지 여부를 확인하는 예측 메소드가 정책에 정의됩니다. 따라서 위 예제에서 FooPolicy의 인스턴스는 #is_public?#thing?에도 응답합니다.

Conditions은 그들의 범위에 따라 캐시됩니다. 범위와 순서는 나중에 다룹니다.

Rules

rule은 조건 및 다른 규칙의 논리적 조합으로, 특정한 능력을 활성화하거나 방지하도록 구성된 것입니다. 규칙 구성은 정적입니다 - 규칙의 논리는 데이터베이스를 접근하거나 @user 또는 @subject에 대해 알 수 없습니다. 이를 통해 우리는 condition 레벨에서만 캐시할 수 있습니다. rule 메소드를 통해 규칙이 지정되며, DSL 구성 블록을 취한 후 #enable 또는 #prevent에 응답하는 객체를 반환합니다.

class FooPolicy < BasePolicy
  # ...
  
  rule { is_public }.enable :read
  rule { ~thing }.prevent :read
  
  # 동등하게,
  rule { is_public }.policy do
    enable :read
  end
  
  rule { ~thing }.policy do
    prevent :read
  end
end

규칙 DSL 내에서는 다음을 사용할 수 있습니다:

  • 일반 단어는 이름으로부터 condition을 언급합니다 - 해당 condition이 참일 때 적용되는 규칙입니다.
  • ~은 부정을 나타내며, negate로도 사용 가능합니다.
  • &|는 논리적 조합으로, all?(...)any?(...)로도 사용 가능합니다.
  • can?(:other_ability):other_ability에 적용되는 규칙에 위임합니다. 이는 동적으로 확인할 수 있는 인스턴스 메소드 can?과 구분됩니다 - 이것은 다른 능력에 대한 위임을 구성하는 것으로, 동적으로 확인하지 않습니다.

~, &, | 연산자는 DeclarativePolicy::Rule::Base에서 오버라이드된 메소드입니다.

규칙 DSL 내에서 불리언 연산자 (&&, ||), 삼항 연산자 (condition ? ... : ...), if 블록과 같은 사용하지 말아야 합니다. 규칙 블록 내의 조건은 부울 값이 아니라 객체이므로 이러한 연산자를 사용할 수 없습니다. 이러한 연산자는 오버라이드될 수 없으며, 따라서 사용자 정의 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에서 기자 역할을 가지고 있기 때문에 해당 규칙이 활성화되었음을 볼 수 있습니다.

정책이 특정한 능력이 허용되었는지 물어볼 때 (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)를 호출할 때, condition을 두 번 계산해야 할 것입니다 - 각각 다른 사용자에게 대한 것이므로. 그러나 scope: :subject 옵션을 사용하면:

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

그러면 condition의 결과는 주제에만 기반하여 전역적으로 캐시되므로, 다른 사용자의 경우에도 다시 계산할 필요가 없습니다. 마찬가지로 scope: :user는 사용자에 의해 캐시되고, 단지 Subject에서만 사용되는 것됩니다.

주의: 사용자와 주제 모두(익명 체크도 포함)의 데이터를 사용할 때 :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?을 확인하는 것을 선호합니다.

위임

위임(Delegation)은 다른 주제의 정책에서 규칙을 포함하는 것입니다. 예를 들어:

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

이 정의에서 ChildPolicyParentPolicy절대로 :eat_broccoli를 충족시키기 위해 보지 않지만, 다른 능력에 대해서는 사용합니다. 그러면 자식 정책은 :eat_broccoliChild에 맞게 정의할 수 있고 Parent에 맞게 정의할 수 없습니다.

overrides 사용 대안

정책 위임을 무시하는 것은 위임이 복잡한 이유와 동일한 이유로 복잡합니다 - 논리적 인퍼런스에 대한 추론과 의미에 대해 명확해야하기 때문입니다. override의 남용은 코드를 중복시키고 보안 버그를 도입할 수 있는 잠재적인 위험이 있기 때문에 다른 접근 방법이 불가능할 때에만 사용해야 합니다.

다른 접근 방법은 예를 들어 다른 능력 이름을 사용하는 것일 수 있습니다. 음식을 선택하는 것과 주어진 음식을 먹는 것은 의미상으로 다르며, 이들은 서로 다른 이름을 갖을 수 있습니다(이 경우에는 chooses_to_eat_broccolieats_what_is_given일 수 있습니다). 이것은 호출지 방식이 다형적인지에 따라 달라질 수 있습니다. Parent 또는 Child로 항상 정책을 확인한다는 것을 알고 있다면 적절한 능력 이름을 선택할 수 있습니다. 호출지가 다형적이면 그렇게 할 수 없습니다.

정책 클래스 지정

특정 주제에 대해 사용되는 정책을 재정의할 수도 있습니다:

class Foo
  
  def self.declarative_policy_class
    'SomeOtherPolicy'
  end
end

이것은 일반적으로 계산된 FooPolicy 클래스 대신 SomeOtherPolicy 클래스에서 권한을 사용하고 확인합니다.