declarative_policy-1.1.0/0000755000004100000410000000000014153430526015406 5ustar www-datawww-datadeclarative_policy-1.1.0/Gemfile.lock0000644000004100000410000001206214153430526017631 0ustar www-datawww-dataPATH remote: . specs: declarative_policy (1.1.0) GEM remote: https://rubygems.org/ specs: abstract_type (0.0.7) activesupport (6.1.3.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) adamantium (0.2.0) ice_nine (~> 0.11.0) memoizable (~> 0.4.0) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) ast (2.4.2) benchmark (0.1.1) binding_ninja (0.2.3) binding_ninja (0.2.3-java) byebug (11.1.3) claide (1.0.3) claide-plugins (0.9.2) cork nap open4 (~> 1.3) coderay (1.1.3) colored2 (3.1.2) concord (0.1.5) adamantium (~> 0.2.0) equalizer (~> 0.0.9) concurrent-ruby (1.1.8) cork (0.3.0) colored2 (~> 3.1) danger (8.2.3) claide (~> 1.0) claide-plugins (>= 0.9.2) colored2 (~> 3.1) cork (~> 0.1) faraday (>= 0.9.0, < 2.0) faraday-http-cache (~> 2.0) git (~> 1.7) kramdown (~> 2.3) kramdown-parser-gfm (~> 1.0) no_proxy_fix octokit (~> 4.7) terminal-table (>= 1, < 4) danger-gitlab (8.0.0) danger gitlab (~> 4.2, >= 4.2.0) diff-lcs (1.4.4) equalizer (0.0.11) faraday (1.4.1) faraday-excon (~> 1.1) faraday-net_http (~> 1.0) faraday-net_http_persistent (~> 1.1) multipart-post (>= 1.2, < 3) ruby2_keywords (>= 0.0.4) faraday-excon (1.1.0) faraday-http-cache (2.2.0) faraday (>= 0.8) faraday-net_http (1.0.1) faraday-net_http_persistent (1.1.0) ffi (1.15.4-java) git (1.8.1) rchardet (~> 1.8) gitlab (4.17.0) httparty (~> 0.18) terminal-table (~> 1.5, >= 1.5.1) gitlab-dangerfiles (1.1.1) danger-gitlab gitlab-styles (6.1.0) rubocop (~> 0.91, >= 0.91.1) rubocop-gitlab-security (~> 0.1.1) rubocop-performance (~> 1.9.2) rubocop-rails (~> 2.9) rubocop-rspec (~> 1.44) httparty (0.18.1) mime-types (~> 3.0) multi_xml (>= 0.5.2) i18n (1.8.10) concurrent-ruby (~> 1.0) ice_nine (0.11.2) kramdown (2.3.1) rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) memoizable (0.4.2) thread_safe (~> 0.3, >= 0.3.1) method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) mime-types-data (3.2021.0225) minitest (5.14.4) multi_xml (0.6.0) multipart-post (2.1.1) nap (1.1.0) no_proxy_fix (0.1.2) octokit (4.21.0) faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) open4 (1.3.4) parallel (1.20.1) parser (3.0.0.0) ast (~> 2.4.1) proc_to_ast (0.1.0) coderay parser unparser procto (0.0.3) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) pry (0.13.1-java) coderay (~> 1.1) method_source (~> 1.0) spoon (~> 0.0) pry-byebug (3.9.0) byebug (~> 11.0) pry (~> 0.13.0) public_suffix (4.0.6) rack (2.2.3) rainbow (3.0.0) rake (12.3.3) rchardet (1.8.0) regexp_parser (1.8.2) rexml (3.2.4) rspec (3.10.0) rspec-core (~> 3.10.0) rspec-expectations (~> 3.10.0) rspec-mocks (~> 3.10.0) rspec-core (3.10.1) rspec-support (~> 3.10.0) rspec-expectations (3.10.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.10.0) rspec-mocks (3.10.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.10.0) rspec-parameterized (0.4.2) binding_ninja (>= 0.2.3) parser proc_to_ast rspec (>= 2.13, < 4) unparser rspec-support (3.10.2) rubocop (0.93.1) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8) rexml rubocop-ast (>= 0.6.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) rubocop-ast (1.4.1) parser (>= 2.7.1.5) rubocop-gitlab-security (0.1.1) rubocop (>= 0.51) rubocop-performance (1.9.2) rubocop (>= 0.90.0, < 2.0) rubocop-ast (>= 0.4.0) rubocop-rails (2.9.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 0.90.0, < 2.0) rubocop-rspec (1.44.1) rubocop (~> 0.87) rubocop-ast (>= 0.7.1) ruby-progressbar (1.11.0) ruby2_keywords (0.0.4) sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) spoon (0.0.6) ffi terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) thread_safe (0.3.6) thread_safe (0.3.6-java) tzinfo (2.0.4) concurrent-ruby (~> 1.0) unicode-display_width (1.7.0) unparser (0.4.7) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) concord (~> 0.1.5) diff-lcs (~> 1.3) equalizer (~> 0.0.9) parser (>= 2.6.5) procto (~> 0.0.2) zeitwerk (2.4.2) PLATFORMS ruby universal-java-1.8 DEPENDENCIES benchmark declarative_policy! gitlab-dangerfiles (~> 1.1.0) gitlab-styles (~> 6.1.0) pry-byebug rake (~> 12.0) rspec (~> 3.10) rspec-parameterized rubocop BUNDLED WITH 2.2.15 declarative_policy-1.1.0/.rspec0000644000004100000410000000010214153430526016514 0ustar www-datawww-data--format documentation --color --require spec_helper --order rand declarative_policy-1.1.0/README.md0000644000004100000410000001220514153430526016665 0ustar www-datawww-data# `DeclarativePolicy`: A Declarative Authorization Library [![Gem Version](https://badge.fury.io/rb/declarative_policy.svg)](https://badge.fury.io/rb/declarative_policy) This library provides a DSL for writing authorization policies. It can be used to separate logic from permissions, and has been used at scale in production at [GitLab.com](https://gitlab.com). The original author of this library is [Jeanine Adkisson](http://jneen.net), and copyright is held by GitLab. ## Installation Add this line to your application's Gemfile: ```ruby gem 'declarative_policy' ``` And then execute: $ bundle install Or install it yourself as: $ gem install declarative_policy ## Usage The core abstraction of this library is a `Policy`. Policies combine: - **facts** (called `conditions`) about the state of the world - **judgements** about these facts (called `rules`) This library exists to determine the truth value of statements of the form: ``` Subject Predicate [Object] ``` For example: - `user :is_alive` - `user :can_drive car` - `user :can_sell car` It does this by letting us associate a `Policy` (a set of rules about which statements are true) with the objects of the sentences. A statement is considered to hold if no rule `prevents` it, and at least one rule `enables` it. For example, imagine we have a data model containing vehicles and users, and we want to know if a user can drive a vehicle. We need a `VehiclePolicy`: ```ruby class VehiclePolicy < DeclarativePolicy::Base # relevant facts condition(:owns) { @subject.owner == @user } condition(:has_access_to) { @subject.owner.trusts?(@user) } condition(:old_enough_to_drive) { @user.age >= laws.minimum_age } condition(:has_driving_license) { @user.driving_license&.valid? } # expensive rules can have 'score'. Higher scores are 'more expensive' to calculate condition(:owns, score: 0) { @subject.owner == @user } condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) } condition(:intoxicated, score: 5) { @user.blood_alcohol > laws.max_blood_alcohol } # conclusions we can draw: rule { owns }.enable :drive_vehicle rule { has_access_to }.enable :drive_vehicle rule { ~old_enough_to_drive }.prevent :drive_vehicle rule { intoxicated }.prevent :drive_vehicle rule { ~has_driving_license }.prevent :drive_vehicle # we can use methods to abstract common logic def laws @subject.registration.country.driving_laws end end ``` A few points to note: we could have written this as one big rule (`(owns | has_access_to) & old_enough_to_drive & ~intoxicated & has_driving_license`) but we can see some of the features that make declarative policies scalable for large systems: rules can be broken up into small elements, and composed into larger rules. New conditions and rules can be added at any time. What is more difficult to see is that many performance optimizations are handled for us transparently: - more expensive conditions are called later - we automatically get the desired groupings (evaluate all conditions that might prevent an action, but stop once we have at least one call to enable). - intermediate values are cached. - policies support inheritance and delegation, meaning authorization logic remains DRY. In short this library aims to be declarative: we declare the rules that are important, and the library arranges how to evaluate them. Caching is a particularly valuable feature of policies. If we add new rules about selling a vehicle, for example: ```ruby rule { owns }.enable :sell_vehicle ``` Then the fact of ownership can be shared between different calls to the policy, saving database calls and other expensive IO operations. ### Evaluating a policy: We can check the determination of a policy with: ```ruby cache = Session.current_session policy = DeclarativePolicy.policy_for(user, car, cache: cache) policy.can?(:drive_vehicle) ``` For more usage details, see the [documentation](doc). ## Development After checking out the repository, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). ## Contributing Bug reports and merge requests are welcome on GitLab at https://gitlab.com/gitlab-org/declarative-policy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [GitLab code of conduct](https://about.gitlab.com/community/contribute/code-of-conduct/). ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of Conduct Everyone interacting in the `DeclarativePolicy` project's codebase, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/declarative-policy/blob/master/CODE_OF_CONDUCT.md). declarative_policy-1.1.0/CHANGELOG.md0000644000004100000410000000030114153430526017211 0ustar www-datawww-data1.1.0: - Add cache invalidation API: `DeclarativePolicy.invalidate(cache, keys)` - Include actor class name in cache key 1.0.1: - Added unit level tests for `lib/declarative_policy/rule.rb` declarative_policy-1.1.0/.rubocop.yml0000644000004100000410000000030114153430526017652 0ustar www-datawww-datainherit_gem: gitlab-styles: - rubocop-default.yml CodeReuse/ActiveRecord: Enabled: false AllCops: TargetRubyVersion: 2.5 NewCops: enable RSpec/MultipleMemoizedHelpers: Max: 10 declarative_policy-1.1.0/.gitignore0000644000004100000410000000022314153430526017373 0ustar www-datawww-data/.bundle/ /.yardoc /_yardoc/ /coverage/ /pkg/ /spec/reports/ /tmp/ # rspec failure tracking .rspec_status declarative_policy-*.gem .tool-versions declarative_policy-1.1.0/danger/0000755000004100000410000000000014153430526016646 5ustar www-datawww-datadeclarative_policy-1.1.0/danger/roulette/0000755000004100000410000000000014153430526020511 5ustar www-datawww-datadeclarative_policy-1.1.0/danger/roulette/Dangerfile0000644000004100000410000000654514153430526022506 0ustar www-datawww-data# frozen_string_literal: true require 'digest/md5' MESSAGE = < :engineering_productivity, %r{\Alefthook.yml\z} => :engineering_productivity, %r{\A\.editorconfig\z} => :engineering_productivity, %r{Dangerfile\z} => :engineering_productivity, %r{\A(danger/|tooling/danger/)} => :engineering_productivity, %r{\A?scripts/} => :engineering_productivity, %r{\Atooling/} => :engineering_productivity, %r{(CODEOWNERS)} => :engineering_productivity, %r{\A(Gemfile|Gemfile.lock|Rakefile)\z} => :backend, %r{\A\.rubocop((_manual)?_todo)?\.yml\z} => :backend, %r{\.rb\z} => :backend, %r{( \.(md|txt)\z | \.markdownlint\.json )}x => :docs }.freeze # rubocop: enable Style/RegexpLiteral def changes_by_category helper.changes_by_category(CATEGORIES) end def changes helper.changes(CATEGORIES) end def rule_names helper.ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES end def project_name # 'declarative-policy' # TODO: roulette uses the project name to find reviewers, but the gitlab team # directory currently does not have any team members assigned to the declarative-policy # project. We thus are piggybacking on 'gitlab' for now. 'gitlab' end end end declarative_policy-1.1.0/CODE_OF_CONDUCT.md0000644000004100000410000000624114153430526020210 0ustar www-datawww-data# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at akalderimis@gitlab.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] [homepage]: https://contributor-covenant.org [version]: https://contributor-covenant.org/version/1/4/ declarative_policy-1.1.0/Rakefile0000644000004100000410000000022114153430526017046 0ustar www-datawww-data# frozen_string_literal: true require 'bundler/gem_tasks' require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) task default: :spec declarative_policy-1.1.0/lib/0000755000004100000410000000000014153430526016154 5ustar www-datawww-datadeclarative_policy-1.1.0/lib/declarative_policy.rb0000644000004100000410000000610714153430526022347 0ustar www-datawww-data# frozen_string_literal: true require 'set' require_relative 'declarative_policy/cache' require_relative 'declarative_policy/condition' require_relative 'declarative_policy/delegate_dsl' require_relative 'declarative_policy/policy_dsl' require_relative 'declarative_policy/rule_dsl' require_relative 'declarative_policy/preferred_scope' require_relative 'declarative_policy/rule' require_relative 'declarative_policy/runner' require_relative 'declarative_policy/step' require_relative 'declarative_policy/base' require_relative 'declarative_policy/nil_policy' require_relative 'declarative_policy/configuration' # DeclarativePolicy: A DSL based authorization framework module DeclarativePolicy extend PreferredScope class << self def policy_for(user, subject, opts = {}) cache = opts[:cache] || {} key = Cache.policy_key(user, subject) cache[key] ||= class_for(subject).new(user, subject, opts) end # Find the list of runners with now invalidated keys, and invalidate the runners def invalidate(cache, invalidated_keys) return unless cache&.any? return unless invalidated_keys&.any? keys = invalidated_keys.to_set policies = cache.select { |k, _| k.is_a?(String) && k.start_with?('/dp/policy/') } policies.each_value do |policy| policy.runners.each do |runner| runner.uncache! if keys.intersect?(runner.dependencies) end end invalidated_keys.each { |k| cache.delete(k) } nil end def class_for(subject) return configuration.nil_policy if subject.nil? return configuration.named_policy(subject) if subject.is_a?(Symbol) subject = find_delegate(subject) policy_class = class_for_class(subject.class) raise "no policy for #{subject.class.name}" if policy_class.nil? policy_class end def configure(&block) configuration.instance_eval(&block) nil end # Reset configuration def configure!(&block) @configuration = DeclarativePolicy::Configuration.new configure(&block) if block end def policy?(subject) !class_for_class(subject.class).nil? end alias_method :has_policy?, :policy? private def configuration @configuration ||= DeclarativePolicy::Configuration.new end def class_for_class(subject_class) if subject_class.respond_to?(:declarative_policy_class) Object.const_get(subject_class.declarative_policy_class) else subject_class.ancestors.each do |klass| name = klass.name klass = policy_class(name) return klass if klass end nil end end def policy_class(name) clazz = configuration.policy_class(name) clazz if clazz && clazz < Base end def find_delegate(subject) seen = Set.new while subject.respond_to?(:declarative_policy_delegate) raise ArgumentError, 'circular delegations' if seen.include?(subject.object_id) seen << subject.object_id subject = subject.declarative_policy_delegate end subject end end end declarative_policy-1.1.0/lib/declarative_policy/0000755000004100000410000000000014153430526022016 5ustar www-datawww-datadeclarative_policy-1.1.0/lib/declarative_policy/rule_dsl.rb0000644000004100000410000000176714153430526024167 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy # The DSL evaluation context inside rule { ... } blocks. # Responsible for creating and combining Rule objects. # # See Base.rule class RuleDsl def initialize(context_class) @context_class = context_class end def can?(ability) Rule::Ability.new(ability) end def all?(*rules) Rule::And.make(rules) end def any?(*rules) Rule::Or.make(rules) end def none?(*rules) ~Rule::Or.new(rules) end def cond(condition) Rule::Condition.new(condition) end def delegate(delegate_name, condition) Rule::DelegatedCondition.new(delegate_name, condition) end def method_missing(msg, *args) return super unless args.empty? && !block_given? if @context_class.delegations.key?(msg) DelegateDsl.new(self, msg) else cond(msg.to_sym) end end def respond_to_missing?(symbol, include_all) true end end end declarative_policy-1.1.0/lib/declarative_policy/runner.rb0000644000004100000410000001446214153430526023663 0ustar www-datawww-data# frozen_string_literal: true require 'set' module DeclarativePolicy class Runner class State attr_reader :called_conditions def initialize @enabled = false @prevented = false @called_conditions = Set.new end def enable! @enabled = true end def enabled? @enabled end def prevent! @prevented = true end def prevented? @prevented end def pass? !prevented? && enabled? end def register(manifest_condition) @called_conditions << manifest_condition.cache_key end end # a Runner contains a list of Steps to be run. attr_reader :steps def initialize(steps) @steps = steps @state = nil end # We make sure only to run any given Runner once, # and just continue to use the resulting @state # that's left behind. def cached? !!@state end # Delete the cached state - allowing this runner to be re-used if the facts have changed. def uncache! @state = nil end # used by Rule::Ability. See #steps_by_score def score return 0 if cached? steps.sum(&:score) end def merge_runner(other) Runner.new(@steps + other.steps) end def dependencies return Set.new unless @state @state.called_conditions end # The main entry point, called for making an ability decision. # See #run and DeclarativePolicy::Base#can? def pass? run unless cached? parent_state = Thread.current[:declarative_policy_current_runner_state] parent_state&.called_conditions&.merge(@state.called_conditions) @state.pass? end # see DeclarativePolicy::Base#debug def debug(out = $stderr) run(out) end private def with_state(&block) @state = State.new old_runner_state = Thread.current[:declarative_policy_current_runner_state] Thread.current[:declarative_policy_current_runner_state] = @state yield ensure Thread.current[:declarative_policy_current_runner_state] = old_runner_state end def flatten_steps! @steps = @steps.flat_map { |s| s.flattened(@steps) } end # This method implements the semantic of "one enable and no prevents". # It relies on #steps_by_score for the main loop, and updates @state # with the result of the step. def run(debug = nil) with_state do steps_by_score(!!debug) do |step, score| break if !debug && @state.prevented? passed = nil case step.action when :enable # we only check :enable actions if they have a chance of # changing the outcome - if no other rule has enabled or # prevented. unless @state.enabled? || @state.prevented? passed = step.pass? @state.enable! if passed end when :prevent # we only check :prevent actions if the state hasn't already # been prevented. unless @state.prevented? passed = step.pass? @state.prevent! if passed end else raise "invalid action #{step.action.inspect}" end debug << inspect_step(step, score, passed) if debug end end @state end # This is the core spot where all those `#score` methods matter. # It is critical for performance to run steps in the correct order, # so that we don't compute expensive conditions (potentially n times # if we're called on, say, a large list of users). # # In order to determine the cheapest step to run next, we rely on # Step#score, which returns a numerical rating of how expensive # it would be to calculate - the lower the better. It would be # easy enough to statically sort by these scores, but we can do # a little better - the scores are cache-aware (conditions that # are already in the cache have score 0), which means that running # a step can actually change the scores of other steps. # # So! The way we sort here involves re-scoring at every step. This # is by necessity quadratic, but most of the time the number of steps # will be low. But just in case, if the number of steps exceeds 50, # we print a warning and fall back to a static sort. # # For each step, we yield the step object along with the computed score # for debugging purposes. def steps_by_score(debugging) flatten_steps! if @steps.size > 50 warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort" @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)| yield step, score end return end remaining_steps = Set.new(@steps) remaining_enablers, remaining_preventers = remaining_steps.partition(&:enable?).map { |s| Set.new(s) } loop do if @state.enabled? # Once we set this, we never need to unset it, because a single # prevent will stop this from being enabled remaining_steps = remaining_preventers elsif remaining_enablers.empty? # if the permission hasn't yet been enabled and we only have # prevent steps left, we short-circuit the state here @state.prevent! return unless debugging end return if remaining_steps.empty? next_step, lowest_score = next_step_and_score(remaining_steps) [remaining_steps, remaining_enablers, remaining_preventers].each do |set| set.delete(next_step) end yield next_step, lowest_score end end def next_step_and_score(remaining_steps) lowest_score = Float::INFINITY next_step = nil remaining_steps.each do |step| score = step.score if score < lowest_score next_step = step lowest_score = score end break if lowest_score.zero? end [next_step, lowest_score] end # Formatter for debugging output. def inspect_step(step, original_score, passed) symbol = case passed when true then '+' when false then '-' when nil then ' ' end "#{symbol} [#{original_score.to_i}] #{step.repr}\n" end end end declarative_policy-1.1.0/lib/declarative_policy/version.rb0000644000004100000410000000012014153430526024021 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy VERSION = '1.1.0' end declarative_policy-1.1.0/lib/declarative_policy/cache.rb0000644000004100000410000000135514153430526023412 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy module Cache class << self def user_key(user) return '' if user.nil? "#{user.class.name}:#{id_for(user)}" end def policy_key(user, subject) u = user_key(user) s = subject_key(subject) "/dp/policy/#{u}/#{s}" end def subject_key(subject) return '' if subject.nil? return subject.inspect if subject.is_a?(Symbol) "#{subject.class.name}:#{id_for(subject)}" end private def id_for(obj) id = begin obj.id rescue NoMethodError nil end id || "##{obj.object_id}" end end end end declarative_policy-1.1.0/lib/declarative_policy/condition.rb0000644000004100000410000000671514153430526024342 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy # A Condition is the data structure that is created by the # `condition` declaration on DeclarativePolicy::Base. It is # more or less just a struct of the data passed to that # declaration. It holds on to the block to be instance_eval'd # on a context (instance of Base) later, via #compute. class Condition attr_reader :name, :description, :scope, :manual_score, :context_key def initialize(name, opts = {}, &compute) @name = name @compute = compute @scope = opts.fetch(:scope, :normal) @description = opts.delete(:description) @context_key = opts[:context_key] @manual_score = opts.fetch(:score, nil) end def compute(context) !!context.instance_eval(&@compute) end def key "#{@context_key}/#{@name}" end end # In contrast to a Condition, a ManifestCondition contains # a Condition and a context object, and is capable of calculating # a result itself. This is the return value of Base#condition. class ManifestCondition def initialize(condition, context) @condition = condition @context = context end # The main entry point - does this condition pass? We reach into # the context's cache here so that we can share in the global # cache (often RequestStore or similar). def pass? Thread.current[:declarative_policy_current_runner_state]&.register(self) @context.cache(cache_key) { @condition.compute(@context) } end # Whether we've already computed this condition. def cached? @context.cached?(cache_key) end # This is used to score Rule::Condition. See Rule::Condition#score # and Runner#steps_by_score for how scores are used. # # The number here is intended to represent, abstractly, how # expensive it would be to calculate this condition. # # See #cache_key for info about @condition.scope. def score # If we've been cached, no computation is necessary. return 0 if cached? # Use the override from condition(score: ...) if present return @condition.manual_score if @condition.manual_score # Global scope rules are cheap due to max cache sharing return 2 if @condition.scope == :global # "Normal" rules can't share caches with any other policies return 16 if @condition.scope == :normal # otherwise, we're :user or :subject scope, so it's 4 if # the caller has declared a preference return 4 if @condition.scope == DeclarativePolicy.preferred_scope # and 8 for all other :user or :subject scope conditions. 8 end # This method controls the caching for the condition. This is where # the condition(scope: ...) option comes into play. Notice that # depending on the scope, we may cache only by the user or only by # the subject, resulting in sharing across different policy objects. def cache_key @cache_key ||= case @condition.scope when :normal then "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}" when :user then "/dp/condition/#{@condition.key}/#{user_key}" when :subject then "/dp/condition/#{@condition.key}/#{subject_key}" when :global then "/dp/condition/#{@condition.key}" else raise 'invalid scope' end end private def user_key Cache.user_key(@context.user) end def subject_key Cache.subject_key(@context.subject) end end end declarative_policy-1.1.0/lib/declarative_policy/delegate_dsl.rb0000644000004100000410000000074514153430526024765 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy # Used when the name of a delegate is mentioned in # the rule DSL. class DelegateDsl def initialize(rule_dsl, delegate_name) @rule_dsl = rule_dsl @delegate_name = delegate_name end def method_missing(msg, *args) return super unless args.empty? && !block_given? @rule_dsl.delegate(@delegate_name, msg) end def respond_to_missing?(msg, include_all) true end end end declarative_policy-1.1.0/lib/declarative_policy/preferred_scope.rb0000644000004100000410000000130314153430526025507 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy module PreferredScope PREFERRED_SCOPE_KEY = :"DeclarativePolicy.preferred_scope" def with_preferred_scope(scope) old_scope = Thread.current[PREFERRED_SCOPE_KEY] Thread.current[PREFERRED_SCOPE_KEY] = scope yield ensure Thread.current[PREFERRED_SCOPE_KEY] = old_scope end def preferred_scope Thread.current[PREFERRED_SCOPE_KEY] end def user_scope(&block) with_preferred_scope(:user, &block) end def subject_scope(&block) with_preferred_scope(:subject, &block) end def preferred_scope=(scope) Thread.current[PREFERRED_SCOPE_KEY] = scope end end end declarative_policy-1.1.0/lib/declarative_policy/step.rb0000644000004100000410000000544414153430526023325 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy # This object represents one step in the runtime decision of whether # an ability is allowed. It contains a Rule and a context (instance # of DeclarativePolicy::Base), which contains the user, the subject, # and the cache. It also contains an "action", which is the symbol # :prevent or :enable. class Step attr_reader :context, :rule, :action def initialize(context, rule, action) @context = context @rule = rule @action = action end # In the flattening process, duplicate steps may be generated in the # same rule. This allows us to eliminate those (see Runner#steps_by_score # and note its use of a Set) def ==(other) @context == other.context && @rule == other.rule && @action == other.action end # In the runner, steps are sorted dynamically by score, so that # we are sure to compute them in close to the optimal order. # # See also Rule#score, ManifestCondition#score, and Runner#steps_by_score. def score # we slightly prefer the preventative actions # since they are more likely to short-circuit case @action when :prevent @rule.score(@context) * (7.0 / 8) when :enable @rule.score(@context) end end def with_action(action) Step.new(@context, @rule, action) end def enable? @action == :enable end def prevent? @action == :prevent end # This rather complex method allows us to split rules into parts so that # they can be sorted independently for better optimization def flattened(roots) case @rule when Rule::Or # A single `Or` step is the same as each of its elements as separate steps @rule.rules.flat_map { |r| Step.new(@context, r, @action).flattened(roots) } when Rule::Ability # This looks like a weird micro-optimization but it buys us quite a lot # in some cases. If we depend on an Ability (i.e. a `can?(...)` rule), # and that ability *only* has :enable actions (modulo some actions that # we already have taken care of), then its rules can be safely inlined. steps = @context.runner(@rule.ability).steps.reject { |s| roots.include?(s) } if steps.all?(&:enable?) # in the case that we are a :prevent step, each inlined step becomes # an independent :prevent, even though it was an :enable in its initial # context. steps.map! { |s| s.with_action(:prevent) } if prevent? steps.flat_map { |s| s.flattened(roots) } else [self] end else [self] end end def pass? @rule.pass?(@context) end def repr "#{@action} when #{@rule.repr} (#{@context.repr})" end end end declarative_policy-1.1.0/lib/declarative_policy/base.rb0000644000004100000410000002566214153430526023270 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy class Base # A map of ability => list of rules together with :enable # or :prevent actions. Used to look up which rules apply to # a given ability. See Base.ability_map class AbilityMap attr_reader :map def initialize(map = {}) @map = map end # This merge behavior is different than regular hashes - if both # share a key, the values at that key are concatenated, rather than # overridden. def merge(other) conflict_proc = proc { |_key, my_val, other_val| my_val + other_val } AbilityMap.new(@map.merge(other.map, &conflict_proc)) end def actions(key) @map[key] ||= [] end def enable(key, rule) actions(key) << [:enable, rule] end def prevent(key, rule) actions(key) << [:prevent, rule] end end class Options def initialize @hash = {} end def []=(key, value) @hash[key.to_sym] = value end def [](key) @hash[key.to_sym] end def to_h @hash end end class << self # The `own_ability_map` vs `ability_map` distinction is used so that # the data structure is properly inherited - with subclasses recursively # merging their parent class. # # This pattern is also used for conditions, global_actions, and delegations. def ability_map if self == Base own_ability_map else superclass.ability_map.merge(own_ability_map) end end def own_ability_map @own_ability_map ||= AbilityMap.new end # an inheritable map of conditions, by name def conditions if self == Base own_conditions else superclass.conditions.merge(own_conditions) end end def own_conditions @own_conditions ||= {} end # a list of global actions, generated by `prevent_all`. these aren't # stored in `ability_map` because they aren't indexed by a particular # ability. def global_actions if self == Base own_global_actions else superclass.global_actions + own_global_actions end end def own_global_actions @own_global_actions ||= [] end # an inheritable map of delegations, indexed by name (which may be # autogenerated) def delegations if self == Base own_delegations else superclass.delegations.merge(own_delegations) end end def own_delegations @own_delegations ||= {} end # all the [rule, action] pairs that apply to a particular ability. # we combine the specific ones looked up in ability_map with the global # ones. def configuration_for(ability) ability_map.actions(ability) + global_actions end ### declaration methods ### def delegate(name = nil, &delegation_block) if name.nil? @delegate_name_counter ||= 0 @delegate_name_counter += 1 name = :"anonymous_#{@delegate_name_counter}" end name = name.to_sym # rubocop: disable GitlabSecurity/PublicSend delegation_block = proc { @subject.__send__(name) } if delegation_block.nil? # rubocop: enable GitlabSecurity/PublicSend own_delegations[name] = delegation_block end # Declare that the given abilities should not be read from delegates. # # This is useful if you have an ability that you want to define # differently in a policy than in a delegated policy, but still want to # delegate all other abilities. # # example: # # delegate { @subect.parent } # # overrides :drive_car, :watch_tv # def overrides(*names) @overrides ||= [].to_set @overrides.merge(names) end # Declares a rule, constructed using RuleDsl, and returns # a PolicyDsl which is used for registering the rule with # this class. PolicyDsl will call back into Base.enable_when, # Base.prevent_when, and Base.prevent_all_when. def rule(&block) rule = RuleDsl.new(self).instance_eval(&block) PolicyDsl.new(self, rule) end # A hash in which to store calls to `desc` and `with_scope`, etc. def last_options @last_options ||= Options.new end def with_options(opts = {}) last_options.to_h.merge!(opts.to_h) end # Declare a description for the following condition. Currently unused, # but opens the potential for explaining to users why they were or were # not able to do something. def desc(description) with_options description: description end # Declare a scope for the following condition. def with_scope(scope) with_options scope: scope end # Declare a score for the following condition. def with_score(score) with_options score: score end # Declares a condition. It gets stored in `own_conditions`, and generates # a query method based on the condition's name. def condition(condition_name, opts = {}, &value) condition_name = condition_name.to_sym condition = Condition.new(condition_name, condition_options(opts), &value) own_conditions[condition_name] = condition define_method(:"#{condition_name}?") { condition(condition_name).pass? } end # These next three methods are mainly called from PolicyDsl, # and are responsible for "inverting" the relationship between # an ability and a rule. We store in `ability_map` a map of # abilities to rules that affect them, together with a # symbol indicating :prevent or :enable. def enable_when(abilities, rule) abilities.each { |a| own_ability_map.enable(a, rule) } end def prevent_when(abilities, rule) abilities.each { |a| own_ability_map.prevent(a, rule) } end # we store global prevents (from `prevent_all`) separately, # so that they can be combined into every decision made. def prevent_all_when(rule) own_global_actions << [:prevent, rule] end private # retrieve and zero out the previously set options (used in .condition) def condition_options(opts) # The context_key distinguishes two conditions of the same name. # For anonymous classes, use object_id. opts[:context_key] ||= (name || object_id) with_options(opts).tap { @last_options = nil } end end # A policy object contains a specific user and subject on which # to compute abilities. For this reason it's sometimes called # "context" within the framework. # # It also stores a reference to the cache, so it can be used # to cache computations by e.g. ManifestCondition. attr_reader :user, :subject def initialize(user, subject, opts = {}) @user = user @subject = subject @cache = opts[:cache] || {} end # helper for checking abilities on this and other subjects # for the current user. def can?(ability, new_subject = :_self) return allowed?(ability) if new_subject == :_self policy_for(new_subject).allowed?(ability) end # This is the main entry point for permission checks. It constructs # or looks up a Runner for the given ability and asks it if it passes. def allowed?(*abilities) abilities.all? { |a| runner(a).pass? } end # The inverse of #allowed?, used mainly in specs. def disallowed?(*abilities) abilities.all? { |a| !runner(a).pass? } end # computes the given ability and prints a helpful debugging output # showing which def debug(ability, *args) runner(ability).debug(*args) end desc 'Unknown user' condition(:anonymous, scope: :user, score: 0) { @user.nil? } desc 'By default' condition(:default, scope: :global, score: 0) { true } def repr "(#{identify_user} : #{identify_subject})" end def identify_user return '' unless @user @user.to_reference rescue NoMethodError "<#{@user.class}: #{@user.object_id}>" end def identify_subject if @subject.respond_to?(:id) "#{@subject.class.name}/#{@subject.id}" else @subject.inspect end end def inspect "#<#{self.class.name} #{repr}>" end # returns a Runner for the given ability, capable of computing whether # the ability is allowed. Runners are cached on the policy (which itself # is cached on @cache), and caches its result. This is how we perform caching # at the ability level. def runner(ability) ability = ability.to_sym runners[ability] ||= begin own_runner = Runner.new(own_steps(ability)) if self.class.overrides.include?(ability) own_runner else delegated_runners = delegated_policies.values.compact.map { |p| p.runner(ability) } delegated_runners.reduce(own_runner, &:merge_runner) end end end def runners @runners ||= {} end # Helpers for caching. Used by ManifestCondition in performing condition # computation. # # NOTE we can't use ||= here because the value might be the # boolean `false` def cache(key) return @cache[key] if cached?(key) @cache[key] = yield end def cached?(key) !@cache[key].nil? end # returns a ManifestCondition capable of computing itself. The computation # will use our own @cache. def condition(name) name = name.to_sym @_conditions ||= {} @_conditions[name] ||= begin raise "invalid condition #{name}" unless self.class.conditions.key?(name) ManifestCondition.new(self.class.conditions[name], self) end end # used in specs - returns true if there is no possible way for any action # to be allowed, determined only by the global :prevent_all rules. def banned? global_steps = self.class.global_actions.map { |(action, rule)| Step.new(self, rule, action) } !Runner.new(global_steps).pass? end # A list of other policies that we've delegated to (see `Base.delegate`) def delegated_policies @delegated_policies ||= self.class.delegations.transform_values do |block| new_subject = instance_eval(&block) # never delegate to nil, as that would immediately prevent_all next if new_subject.nil? policy_for(new_subject) end end def policy_for(other_subject) DeclarativePolicy.policy_for(@user, other_subject, cache: @cache) end protected # constructs steps that come from this policy and not from any delegations def own_steps(ability) rules = self.class.configuration_for(ability) rules.map { |(action, rule)| Step.new(self, rule, action) } end end end declarative_policy-1.1.0/lib/declarative_policy/configuration.rb0000644000004100000410000000171614153430526025217 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy class Configuration ConfigurationError = Class.new(StandardError) def initialize @named_policies = {} @name_transformation = ->(name) { "#{name}Policy" } @class_for = ->(name) { Object.const_get(name) } end def named_policy(name, policy = nil) @named_policies[name] = policy if policy @named_policies[name] || raise(ConfigurationError, "No #{name} policy configured") end def nil_policy(policy = nil) @nil_policy = policy if policy @nil_policy || ::DeclarativePolicy::NilPolicy end def name_transformation(&block) @name_transformation = block nil end def class_for(&block) @class_for = block nil end def policy_class(domain_class_name) return unless domain_class_name @class_for.call((@name_transformation.call(domain_class_name))) rescue NameError nil end end end declarative_policy-1.1.0/lib/declarative_policy/rule.rb0000644000004100000410000001630114153430526023313 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy module Rule # A Rule is the object that results from the `rule` declaration, # usually built using the DSL in `RuleDsl`. It is a basic logical # combination of building blocks, and is capable of deciding, # given a context (instance of DeclarativePolicy::Base) whether it # passes or not. Note that this decision doesn't by itself know # how that affects the actual ability decision - for that, a # `Step` is used. class Base def self.make(*args) new(*args).simplify end # true or false whether this rule passes. # `context` is a policy - an instance of # DeclarativePolicy::Base. def pass?(_context) raise 'abstract' end # same as #pass? except refuses to do any I/O, # returning nil if the result is not yet cached. # used for accurately scoring And/Or def cached_pass?(_context) raise 'abstract' end # abstractly, how long would it take to compute # this rule? lower-scored rules are tried first. def score(_context) raise 'abstract' end # unwrap double negatives and nested and/or def simplify self end # convenience combination methods def or(other) Or.make([self, other]) end def and(other) And.make([self, other]) end def negate Not.make(self) end alias_method :|, :or alias_method :&, :and alias_method :~, :negate def inspect "#" end end # A rule that checks a condition. This is the # type of rule that results from a basic bareword # in the rule dsl (see RuleDsl#method_missing). class Condition < Base def initialize(name) @name = name end # we delegate scoring to the condition. See # ManifestCondition#score. def score(context) context.condition(@name).score end # Let the ManifestCondition from the context # decide whether we pass. def pass?(context) context.condition(@name).pass? end # returns nil unless it's already cached def cached_pass?(context) condition = context.condition(@name) return unless condition.cached? condition.pass? end def description(context) context.class.conditions[@name].description end def repr @name.to_s end end # A rule constructed from DelegateDsl - using a condition from a # delegated policy. class DelegatedCondition < Base # Internal use only - this is rescued each time it's raised. MissingDelegate = Class.new(StandardError) def initialize(delegate_name, name) @delegate_name = delegate_name @name = name end def delegated_context(context) policy = context.delegated_policies[@delegate_name] raise MissingDelegate if policy.nil? policy end def score(context) delegated_context(context).condition(@name).score rescue MissingDelegate 0 end def cached_pass?(context) condition = delegated_context(context).condition(@name) return unless condition.cached? condition.pass? rescue MissingDelegate false end def pass?(context) delegated_context(context).condition(@name).pass? rescue MissingDelegate false end def repr "#{@delegate_name}.#{@name}" end end # A rule constructed from RuleDsl#can?. Computes a different ability # on the same subject. class Ability < Base attr_reader :ability def initialize(ability) @ability = ability end # We ask the ability's runner for a score def score(context) context.runner(@ability).score end def pass?(context) context.allowed?(@ability) end def cached_pass?(context) runner = context.runner(@ability) return unless runner.cached? runner.pass? end def description(_context) "User can #{@ability.inspect}" end def repr "can?(#{@ability.inspect})" end end # Logical `and`, containing a list of rules. Only passes # if all of them do. class And < Base attr_reader :rules def initialize(rules) @rules = rules end def simplify simplified_rules = @rules.flat_map do |rule| simplified = rule.simplify case simplified when And then simplified.rules else [simplified] end end And.new(simplified_rules) end def score(context) return 0 unless cached_pass?(context).nil? # note that cached rules will have score 0 anyways. @rules.sum { |r| r.score(context) } end def pass?(context) # try to find a cached answer before # checking in order cached = cached_pass?(context) return cached unless cached.nil? @rules.sort_by { |r| r.score(context) }.all? { |r| r.pass?(context) } end def cached_pass?(context) @rules.each do |rule| pass = rule.cached_pass?(context) return pass if pass.nil? || pass == false end true end def repr "all?(#{rules.map(&:repr).join(', ')})" end end # Logical `or`. Mirrors And. class Or < Base attr_reader :rules def initialize(rules) @rules = rules end def pass?(context) cached = cached_pass?(context) return cached unless cached.nil? @rules.sort_by { |r| r.score(context) }.any? { |r| r.pass?(context) } end def simplify simplified_rules = @rules.flat_map do |rule| simplified = rule.simplify case simplified when Or then simplified.rules else [simplified] end end Or.new(simplified_rules) end def cached_pass?(context) @rules.each do |rule| pass = rule.cached_pass?(context) return pass if pass.nil? || pass == true end false end def score(context) return 0 unless cached_pass?(context).nil? @rules.sum { |r| r.score(context) } end def repr "any?(#{@rules.map(&:repr).join(', ')})" end end class Not < Base attr_reader :rule def initialize(rule) @rule = rule end def simplify case @rule when And then Or.new(@rule.rules.map(&:negate)).simplify # DeMorgan's laws when Or then And.new(@rule.rules.map(&:negate)).simplify # DeMorgan's laws when Not then @rule.rule.simplify # double negation else Not.new(@rule.simplify) end end def pass?(context) !@rule.pass?(context) end def cached_pass?(context) case @rule.cached_pass?(context) when nil then nil when true then false when false then true end end def score(context) @rule.score(context) end def repr "~#{@rule.repr}" end end end end declarative_policy-1.1.0/lib/declarative_policy/policy_dsl.rb0000644000004100000410000000234314153430526024506 0ustar www-datawww-data# frozen_string_literal: true module DeclarativePolicy # The return value of a rule { ... } declaration. # Can call back to register rules with the containing # Policy class (context_class here). See Base.rule # # Note that the #policy method just performs an #instance_eval, # which is useful for multiple #enable or #prevent calls. # # Also provides a #method_missing proxy to the context # class's class methods, so that helper methods can be # defined and used in a #policy { ... } block. class PolicyDsl def initialize(context_class, rule) @context_class = context_class @rule = rule end def policy(&block) instance_eval(&block) end def enable(*abilities) @context_class.enable_when(abilities, @rule) end def prevent(*abilities) @context_class.prevent_when(abilities, @rule) end def prevent_all @context_class.prevent_all_when(@rule) end def method_missing(msg, *args, &block) return super unless @context_class.respond_to?(msg) @context_class.__send__(msg, *args, &block) # rubocop: disable GitlabSecurity/PublicSend end def respond_to_missing?(msg) @context_class.respond_to?(msg) || super end end end declarative_policy-1.1.0/lib/declarative_policy/nil_policy.rb0000644000004100000410000000027214153430526024505 0ustar www-datawww-data# frozen_string_literal: true # Default policy definition for nil values module DeclarativePolicy class NilPolicy < DeclarativePolicy::Base rule { default }.prevent_all end end declarative_policy-1.1.0/CONTRIBUTING.md0000644000004100000410000000424214153430526017641 0ustar www-datawww-data## Developer Certificate of Origin and License By contributing to GitLab B.V., you accept and agree to the following terms and conditions for your present and future contributions submitted to GitLab B.V. Except for the license granted herein to GitLab B.V. and recipients of software distributed by GitLab B.V., you reserve all right, title, and interest in and to your Contributions. All contributions are subject to the Developer Certificate of Origin and license set out at [docs.gitlab.com/ce/legal/developer_certificate_of_origin](https://docs.gitlab.com/ce/legal/developer_certificate_of_origin). _This notice should stay as the first item in the CONTRIBUTING.md file._ ## Code of conduct As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Instances of abusive, harassing, or otherwise unacceptable behavior can be reported by emailing contact@gitlab.com. This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org), version 1.1.0, available at [https://contributor-covenant.org/version/1/1/0/](https://contributor-covenant.org/version/1/1/0/). declarative_policy-1.1.0/doc/0000755000004100000410000000000014153430526016153 5ustar www-datawww-datadeclarative_policy-1.1.0/doc/optimization.md0000644000004100000410000002331114153430526021223 0ustar www-datawww-data# Optimization This library cares a lot about performance, and includes features that aim to limit the impact of permission checks on an application. In particular, effort is made to ensure that repeated checks of the same permission are efficient, aiming to eliminate repeated computation and unnecessary I/O. The key observation: permission checks generally involve some facts about the real world, and this involves (relatively expensive) I/O to compute. These facts are then combined in some way to generate a judgment. Not all facts are necessary to know in order to determine a judgment. The main aims of the library: - Avoid unnecessary work. - If we must do work, do the least work possible. The library enables you to define both how to compute these facts (conditions), and how to combine them (rules), but the library is entirely responsible for the scheduling of when to compute each fact. ## Making truth This library is essentially a build-system for truth - you can think of it as similar to [`make`](https://www.gnu.org/software/make/), but: - Instead of `targets` there are `abilities`. - Instead of `files`, we produce `boolean` values. We have no notion of freshness - uncached conditions are always re-computed, but just like `make`, we try to do the least work possible in order to evaluate the given ability. For the interested, this corresponds to [`memo`](https://hackage.haskell.org/package/build-1.0/docs/src/Build.System.html#memo) in the taxonomy of build systems (although the scheduler here is somewhat smarter about the relative order of dependencies). ## Optimization is reducing computation of expensive I/O In the context of this library, optimization refers to ways we can: - Expose the smallest possible units of I/O to the scheduler. - Never run a computation twice. - Indicate to the scheduler which computations should be run first. For example, if a policy defines the following rule: ```ruby rule { fact_a & fact_b }.enable :some_ability ``` The core of the matter: if we know in advance that `fact_a == false`, then we do not need to compute `fact_b`. Conversely, if we know in advance that `fact_b == false`, then we do not need to run `fact_a`. The same goes for `fact_a | fact_a`. In this case: - The smallest possible units of I/O are `fact_a` and `fact_b`, and the library is aware of them. - The library uses the [cache](./caching.md) to avoid running a condition more than once. - It does not matter which order we run these conditions in - the scheduler is free to re-order them if it thinks that `fact_b` is somehow more efficient to compute than `fact_a`. ## The scheduling logic The problem each permission check seeks to solve is determining the truth value of a proposition of the form: ```pseudo any? enabling-conditions && not (any? preventing-conditions) ``` If `[a, b, c]` are enabling conditions, and `[x, y, z]` are preventing conditions, then this could be expressed as: ```ruby (a | b | c) & ~x & ~y & ~z ``` But the [scheduler](../lib/declarative_policy/runner.rb) represents this as a flat list of rules - conditions and their outcomes: ```pseudo [ (a, :enable), (b, :enable), (c, :enable), (x, :prevent), (y, :prevent), (z, :prevent) ] ``` They aren't necessarily run in this order, however. Instead, we try to order the list to minimize unnecessary work. The [logic](https://gitlab.com/gitlab-org/declarative-policy/blob/659ac0525773a76cf8712d47b3c2dadd03b758c9/lib/declarative_policy/runner.rb#L80-112) to process this list is (in pseudo-code): ```pseudo while any-enable-rule-remains?(rules) rule := pop-cheapest-remaining-rule(rules) fact := observe-io-and-update-cache rule.condition if fact and rule.prevents? return prevented else if fact and rule.enables? skip-all-other-enabling-rules! enabled? := true if enabled? return enabled else return prevented ``` The process for ordering rules is that each condition has a score, and we prefer the rules with the lowest `score`. Cached values have a score of `0`. Composite conditions (such as `a | b | c`) have a score that the sum of the scores of their components. The evaluation of one rule results in updating the cache, so other rules might become cheaper, during policy evaluation. To take this into account, we re-score the set of rules on each iteration of the main loop. ## Consequences for the policy-writer While interesting in its own right, this has some practical consequences for the policy writer: ### Flat is better than nested The scheduler can do a better job of arranging work into the smallest possible chunks if the definitions are as flat as possible, meaning this: ```ruby rule { condition_a }.enable :some_ability rule { condition_b }.prevent :some_ability ``` Is easier to optimise than: ```ruby rule { condition_a & ~condition_b }.enable :some_ability ``` We do attempt to flatten and de-nest logical expressions, but it is not always possible to raise all expressions to the top level. All things being equal, we recommend using the declarative style. #### An example of sub-optimal scheduling The scheduler is only able to re-order conditions that can be flattened out to the top level. For example, given the following definition: ```ruby condition(:a, score: 1) { ... } condition(:b, score: 2) { ... } condition(:c, score: 3) { ... } rule { a & c }.enable :some_ability rule { b & c }.enable :some_ability ``` The conditions are evaluated in the following order: - `a & c` (score = 4): - `a` (score = 1) - `c` (score = 3) - `b & c` (score = 3): - `c` (score = 0 [cached]) - `b` (score = 2) If instead this were three top level rules: ```ruby rule { a }.enable :some_ability rule { b }.enable :some_ability rule { ~c }.prevent :some_ability ``` Then this would be evaluated as: - `a` (score = 1) - `b` (score = 2) - `c` (score = 3) If `a` and `b` fail, then `3` is never evaluated, saving the most expensive call. The total evaluated costs for each arrangement are: | Failing conditions | Nested cost | Flat cost | |--------------------|-----------------|---------------| | none | 4 `(a, c)` | 4 `(a, c)` | | all | 3 `(a, b)` | 3 `(a, b)` | | `a` | 6 `(a, b, c)` | 6 `(a, b, c)` | | `b` | 4 `(a, c)` | 4 `(a, c)` | | `c` | 4 `(a, c, c=0)` | 4 `(a, c)` | | `a` and `b` | 4 `(a, c, c=0)` | 3 `(a, b)` | | `a` and `c` | 6 `(a, b, c)` | 6 `(a, b, c)` | | `b` and `c` | 4 `(a, c, c=0)` | 4 `(a, c)` | While the overall costs for all arrangements are very similar, the flat representation is strictly superior, and does not even need to rely on the cache for this behavior. ### Getting the scope right matters By default, the outcome of each rule is cached against a key like `(rule.condition.key, user.key, subject.key)`. (For more information, read [caching](./caching.md).) This makes sense for some things like: ```ruby condition(:owns_vehicle) { @user == @subject.owner } ``` In this case, the result depends on both the `@user` and the `@subject`. Not all conditions are like that, though! The following condition only refers to the subject: ```ruby condition(:roadworthy) { @subject.warrant_of_fitness.current? } ``` If we cached this against `(user_a, car_a)` and then tested it against `(user_b, car_a)` it would not match, and we would have to re-compute the condition, even though the road-worthiness of a vehicle does not depend on the driver. See [caching](./caching.md) for more discussion on scopes. Because more general conditions are more sharable, all things being equal, it is better to evaluate a condition that might be shared later, rather than one that is less likely to be shared. For this reason, when we sort the rules, we prefer ones with more general scopes to more specific ones. ### Getting the score right matters Each condition has a `score`, which is an abstract weight. By default this is determined by the scope. However, if you know that a condition is very expensive to run, then it makes sense to give it a higher score, meaning it's only evaluated if we really need to. On the other hand, if a condition is very likely to be determinative, then giving it a lower score would ensure we test it first. For example, take two conditions, one which queries the local DB, and one which makes an external API call. If they are otherwise equivalent, calling the database one first is likely to be more efficient, as it might save us needing to make the external API call. Conditions that are [pure](https://en.wikipedia.org/wiki/Pure_function) can even be given a value of `0`, as no I/O is required to compute them. ```ruby condition(:local_db) { @subject.related_object.present? } condition(:pure, score: 0) { @subject.some_attribute? } condition(:external_api, score: API_SCORE) { ExtrnalService.get(@subject.id).ok? } # these are run in the order: pure, local_db, external_api rule { external_api & pure & local_db }.enable :some_ability ``` The other consideration is the likelihood that a condition is determinative. For example, if `condition_a` is true 80% of the time, and `condition_b` is true 20% of the time, then we should prefer to run `condition_a` if these conditions enable an ability (because 80% of the time we don't need to run `condition_b`). But if they prevent an ability, then we would prefer to run `condition_b` first, because again, 80% of the time we can skip `condition_a`. This consideration is more subtle. It requires knowing both the distribution of the condition, and the consequence of its outcome, but this can be used to further optimize the order of evaluation by marking some conditions as more likely to affect the outcome. All things being equal, we prefer to run prevent rules, because they have this property - they are more likely to save extra work. declarative_policy-1.1.0/doc/configuration.md0000644000004100000410000000453214153430526021350 0ustar www-datawww-data# Configuration This library is generally configured by writing policies that match the look-up rules for domain objects (see: [defining policies](./defining-policies.md)). ## Configuration blocks This library can be configured using `DeclarativePolicy.configure` and `DeclarativePolicy.configure!`. Both methods take a block, and they differ only in that `.configure!` ensures that the configuration is pristine, and discards any previous configuration, and `configure` can be called multiple times. ## Handling `nil` values By default, all permission checks on `nil` values are denied. This is controlled by `DeclarativePolicy::NilPolicy`, which is implemented as: ```ruby module DeclarativePolicy class NilPolicy < DeclarativePolicy::Base rule { default }.prevent_all end end ``` If you want to handle `nil` values differently, then you can define your own `nil` policy, and configure it to be used in a configuration block: ```ruby DeclarativePolicy.configure do nil_policy MyNilPolicy end ``` ## Named policies Normally policies are determined by looking up matching policy definitions based on the class of the value. `Symbol` values are treated specially, and these define **named policies**. To define a named policy, use a configuration block: ```ruby DeclarativePolicy.configure do named_policy :global, MyGlobalPolicy end ``` Then it can be used by passing the `:global` symbol as the value in a permission check: ``` policy = DeclarativePolicy.policy_for(the_user, :global) policy.allowed?(:some_ability) ``` This can be useful where there is no object of the permission check (that is, the predicate is **intransitive**). An example might be `:can_log_in`, where there is no suitable object, and the identity of the user is fully sufficient to determine the permission check. Using `:global` is a convention, but any policy name can be used. ## Name transformation By default, policy classes are expected to be named for the domain classes, with a `Policy` suffix. So a domain class of `Foo` would resolve to a `FooPolicy`. This logic can be customized by specifying the `name_transformation` rule. To instead have all policies be placed in a `Policies` namespace, so that `Foo` would have its policy at `Policies::Foo`, we can configure that with: ```ruby DeclarativePolicy.configure do name_transformation { |name| "Policies::#{name}" } end ``` declarative_policy-1.1.0/doc/defining-policies.md0000644000004100000410000001632214153430526022071 0ustar www-datawww-data# Defining policies A policy is a set of conditions and rules for domain objects. They are defined using a DSL, and mapped to domain objects by class name. ## Class name determines policy choice If there is a domain class `Foo`, then we can link it to a policy by defining a class `FooPolicy`. This class can be placed anywhere, as long as it is loaded before the call to `DeclarativePolicy.policy_for`. Our recommendation for large applications, such as Rails apps, is to add a new top-level application directory: `app/policies`, and place all policy definitions in there. If you have an `Invoice` model at `app/models/invoice.rb`, then you would create an `InvoicePolicy` at `app/policies/invoice_policy.rb`. ## Invocation We evaluate policies by instantiating them with `DeclarativePolicy::policy_for`, and then evaluating them with `DeclarativePolicy::Base#allowed?`. You may wish to define a method to abstract policy evaluation. Something like: ```ruby def allowed?(user, ability, object) opts = { cache: Cache.current_cache } # re-using a cache between checks eliminates duplication of work policy = DeclarativePolicy.policy_for(user, object, opts) policy.allowed?(ability) end ``` We will assume the presence of such a method below. ## Defining rules in the DSL The DSL has two primary parts: defining **conditions** and **rules**. For example, imagine we have a data model containing vehicles and users, and we want to know if a user can drive a vehicle. We need a `VehiclePolicy`: ```ruby class VehiclePolicy < DeclarativePolicy::Base # conditions go here by convention # rules go here by convention # helper methods go last end ``` ### Conditions Conditions are facts about the state of the system. They have access to two elements of the proposition: - `@user` - the representation of a user in your system: the *subject* of the proposition. `user` in `allowed?(user, ability, object)`. `@user` may be `nil`, which means that the current user is anonymous (for example this may reflect an unauthenticated request in your system). - `@subject` - any domain object that has an associated policy: the *object* of the predicate of the proposition. `object` in `allowed?(user, ability, object)`. `@subject` is never `nil`. See [handling `nil` values](./configuration.md#handling-nil-values) for details of how to apply policies to `nil` values. They are defined as `condition(name, **options, &block)`, where the block is evaluated in the context of an instance of the policy. For example: ```ruby condition(:owns) { @subject.owner == @user } condition(:has_access_to) { @subject.owner.trusts?(@user) } condition(:old_enough_to_drive) { @user.age >= laws.minimum_age } condition(:has_driving_license) { @user.driving_license&.valid? } condition(:intoxicated, score: 5) { @user.blood_alcohol > laws.max_blood_alcohol } condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) } ``` These can be defined in any order, but we consider it best practice to define conditions at the top of the file. Conditions may call methods of the policy class, which can be helpful for memoizing some intermediate state: ```ruby condition(:full_license) { license.full? } condition(:learner_license) { license.learner? } condition(:hgv_license) { license.heavy_goods? } def license @license ||= Licenses.by_country(@user.country_of_residence).for_user(@user) end ``` Conditions are evaluated at most once, and their values are automatically memoized and cached (see [caching](./caching.md) for more detail). If you want to perform I/O (such as database access) or expensive computations, place this access in a condition. ### Rules Rules are conclusions we can draw based on the facts: ```ruby rule { owns }.enable :drive_vehicle rule { has_access_to }.enable :drive_vehicle rule { ~old_enough_to_drive }.prevent :drive_vehicle rule { intoxicated | ~has_driving_license }.prevent :drive_vehicle ``` Rules are combined such that each ability must be enabled at least once, and not prevented in order to be permitted. So `enable` calls are implicitly combined with `ANY`, and `prevent` calls are implicitly combined with `ALL`. A set of conclusions can be defined for a single condition: ```ruby rule { old_enough_to_drive }.policy do enable :drive_vehicle enable :vote end ``` Rule blocks do not have access to the internal state of the policy, and cannot access the `@user` or `@subject`, or any methods on the policy instance. You should not perform I/O in a rule. They exist solely to define the logical rules of implication and combination between conditions. The available operations inside a rule block are: - Bare words to refer to conditions in the policy, or on any delegate. For example `owns`. This is equivalent to `cond(:owns)`, but as a matter of general style, bare words are preferred. - `~` to negate any rule. For example `~owns`, or `~(intoxicated | banned)`. - `&` or `all?` to combine rules such that all must succeed. For example: `old_enough_to_drive & has_driving_license` or `all?(old_enough_to_drive, has_driving_license)`. - `|` or `any?` to combine rules such that one must succeed. For example: `intoxicated | banned` or `any?(intoxicated, banned)`. - `can?` to refer to the result of evaluating an ability. For example, `can?(:sell_vehicle)`. - `delegate(:delegate_name, :condition_name)` to refer to a specific condition on a named delegate. Use of this is rare, but can be used to handle overrides. For example if a vehicle policy defines a delegate as `delegate :registration`, then we could refer to that as `rule { delegate(:registration, :valid) }`. Note: Be careful not to confuse `DeclarativePolicy::Base.condition` with `DeclarativePolicy::RuleDSL#cond`. - `condition` constructs a condition from a name and a block. For example: `condition(:adult) { @subject.age >= country.age_of_majority }`. - `cond` constructs a rule which refers to a condition by name. For example: `rule { cond(:adult) }.enable :vote`. Use of `cond` is rare - it is nicer to use the bare word form: `rule { adult }.enable :vote`. ### Complex conditions Conditions may be combined in the rule blocks: ```ruby # A or B rule { owns | has_access_to }.enable :drive_vehicle # A and B rule { has_driving_license & old_enough_to_drive }.enable :drive_vehicle # Not A rule { ~has_driving_license }.prevent :drive_vehicle ``` And conditions can be implied from abilities: ```ruby rule { can?(:drive_vehicle) }.enable :drive_taxi ``` ### Delegation Policies may delegate to other policies. For example we could have a `DrivingLicense` class, and a `DrivingLicensePolicy`, which might contain rules like: ```ruby class DrivingLicensePolicy < DeclarativePolicy::Base condition(:expired) { @subject.expires_at <= Time.current } rule { expired }.prevent :drive_vehicle end ``` And a registration policy: ```ruby class RegistrationPolicy < DeclarativePolicy::Base condition(:valid) { @subject.valid_for?(@user.current_location) } rule { ~valid }.prevent :drive_vehicle end ``` Then in our `VehiclePolicy` we can delegate the license and registration checking to these two policies: ```ruby delegate { @user.driving_license } delegate { @subject.registration } ``` This is a powerful mechanism for inferring rules based on relationships between objects. declarative_policy-1.1.0/doc/caching.md0000644000004100000410000003161514153430526020077 0ustar www-datawww-data# Caching This library deals with making observations about the state of a system (usually performing I/O, such as making a database query), and combining these facts into logical propositions. In order to make this performant, the library transparently caches repeated observations of conditions. Understanding how caching works is useful for designing good policies, using them effectively. ## What is cached? If a policy is instantiated with a cache, then the following things will be stored in it: - Policy instances (there will only ever be one policy per `user/subject` pair for the lifetime of the cache). - Condition results The correctness of these cached values depends on the correctness of the cache-keys. We assume the objects in your domain have a `#id` method that fully captures the notion of object identity. See [Cache keys](#cache-keys) for details. All cache keys begin with `"/dp/"`. Policies themselves cache the results of the abilities they compute. Policies distinguish between facts based on the type of the fact: - Boolean facts: implemented with `condition`. - Abilities: implemented with `rule` blocks. - Non-boolean facts: implemented by policy instance methods. For example, consider a policy for countries: ```ruby class CountryPolicy < DeclarativePolicy::Base condition(:citizen) { @user.citizen_of?(country.country_code) } condition(:eu_citizen, scope: :user) { @user.citizen_of?(*Unions::EU) } condition(:eu_member, scope: :subject) { Unions::EU.include?(country.country_code) } condition(:has_visa_waiver) { country.visa_waivers.any? { |c| @user.citizen_of?(c) } } condition(:permanent_resident) { visa_category == :permanent } condition(:has_work_visa) { visa_category == :work } condition(:has_current_visa) { has_visa_waiver? || current_visa.present? } condition(:has_business_visa) { has_visa_waiver? || has_work_visa? || visa_category == :business } condition(:full_rights, score: 20) { citizen? || permanent_resident? } condition(:banned) { country.banned_list.include?(@user) } rule { eu_member & eu_citizen }.enable :freedom_of_movement rule { full_rights | can?(:freedom_of_movement) }.enable :settle rule { can?(:settle) | has_current_visa }.enable :enter_country rule { can?(:settle) | has_business_visa }.enable :attend_meetings rule { can?(:settle) | has_work_visa }.enable :work rule { citizen }.enable :vote rule { ~citizen & ~permanent_resident }.enable :apply_for_visa rule { banned }.prevent :enter_country, :apply_for_visa def current_visa return @current_visa if defined?(@current_visa) @current_visa = country.active_visas.find_by(applicant: @user) end def visa_category current_visa&.category end def country @subject end end ``` This is a reasonably realistic policy - there are a few pieces of state (the country, the list of visa waiver agreements, the list of citizenships the user holds, the kind of visa the user has, if they have one, the current list of banned users), and these are combined to determine a range of abilities (whether one can visit or live in or vote in a certain country). Importantly, these pieces of information are re-used between abilities - the citizenship status is relevant to all abilities, whereas the banned list is only considered on entry and when applying for a new visa). If we imagine that some of these operations are reasonably expensive (fetching the current visa status, or checking the banned list, for example), then it follows that we really care about avoiding re-computation of these facts. In the policy above we can see a few strategies that are taken to avoid this: - Conditions are re-used liberally. - Non-boolean facts are cached at the policy level. ## Re-using conditions Rules can and should re-use conditions as much as possible. Condition observations are cached automatically, so referring to the same condition in multiple rules is encouraged. Conditions can also refer to other conditions by using the predicate methods that are created for them (see `full_rights`, which refers to the `:citizen` condition as `citizen?`). Note that referring to conditions inside other conditions can be DRY, but it limits the ability of the library to optimize the steps (see [optimization](./optimization.md)). For example in the `:has_current_visa` condition, the sub-conditions will always be tested in the order `has_visa_waiver` then `current_visa.present?`. It is recommended not to rely heavily on this kind of abstraction. ## Re-using rules Entire rule-sets can be re-used with `can?`. This is a form of logical implication where a previous conclusion can be used in a further rule. Examples of this here are `can?(:settle)` and `can?(:freedom_of_movement)`. This can prevent having to repeat long groups of conditions in rule definitions. This abstraction is transparent to the optimizer. ## Non-boolean values must be managed manually The condition `has_current_visa` and the more specific `has_{work,business}_visa` all refer to the same piece of state - the `#current_visa`. Since this is not a boolean (but is here a database record with a `#category` attribute), this cannot be a condition, but must be managed by the policy itself. The best approach here is to use normal Ruby methods and instance variables for such values. The policy instances themselves are cached, so that any two invocations of `DeclarativePolicy.policy_for(user, object)` with identical `user` and `object` arguments will always return the same policy object. This means instance variables stored on the policy will be available for the lifetime of the cache. Methods can be used for the usual reasons of clarity (such as referring to the `@subject` as `country`) and brevity (such as `visa_category`). ## Cache lifetime The cache is provided by the user of the library, passing it to the `.policy_for` method. For example: ```ruby DeclarativePolicy.policy_for(user, country, cache: some_cache_value) ``` The object only needs to implement the following methods: - `cache[key: String] -> Boolean?`: Fetch the cached value - `cache.key?(key: String) -> Boolean`: Test if the key is cached - `cache[key: String] = Boolean`: Cache a value Obviously, a `HashMap` will work just fine, but so will a wrapper around a [`Concurrent::Map`](https://ruby-concurrency.github.io/concurrent-ruby/1.1.4/Concurrent/Map.html), or even a map that delegates to Redis with a TTL for each key, so long as the object supports these methods. Keys are never deleted by the library, and values are only computed if the key is not cached, so it is up to the application code to determine the life-time of each key. Clearly, cache-invalidation is a hard problem. At GitLab we share a single cache object for each request - so any single request can freely request a permission check multiple times (or even compute related abilities, such as `:enter_country` and `:settle`) and know that no work is duplicated. This allows developers to reason declaratively, and add permission checks where needed, without worrying about performance. ## Cache sharing: scopes Not all conditions are equally specific. The condition `citizen` refers to both the user and the country, and so can only be used when checking both the user and the country. We say that this is the `normal` scope. This is not always true however. Sometimes a condition refers only to the user. For example, above we have two conditions: `eu_citizen` and `eu_member`: ```ruby condition(:eu_citizen, scope: :user) { @user.citizen_of?(*Unions::EU) } condition(:eu_member, scope: :subject) { Unions::EU.include?(country.country_code) } ``` `eu_citizen` refers only to the user, and `eu_member` refers only to the country. If we have a user that wants to enter multiple countries on a grand European tour, we could check this with: ```ruby itinerary.countries.all? { |c| DeclarativePolicy.policy_for(user, c).allowed?(:enter_country) } ``` If `eu_citizen` were declared with the `normal` scope, then this would have a lot of cache misses. By using the `:user` scope on `eu_citizen`, we only check EU citizenship once. Similarly for `eu_member`, if a team of football players want to visit a country, then we could check this with: ```ruby team.players.all? { |user| DeclarativePolicy.policy_for(user, country).allowed?(:enter_country) } ``` Again, by declaring `eu_member` as having the `:subject` scope, this ensures we only check EU membership once, not once for each football player. The last scope is `:global`, used when the condition is universally true: ```ruby condition(:earth_destroyed_by_meteor, scope: global) { !Planet::Earth.exists? } rule { earth_destroyed_by_meteor }.prevent_all ``` In this case, it doesn't matter who the user is or even where they are going: the condition will be computed once (per cache lifetime) for all combinations. Because of the implications for sharing, the scope determines the [`#score`](https://gitlab.com/gitlab-org/declarative-policy/blob/2ab9dbdf44fb37beb8d0f7c131742d47ae9ef5d0/lib/declarative_policy/condition.rb#L58-77) of the condition (if not provided explicitly). The intention is to prefer values we are more likely (all other things being equal) to re-use: - Conditions we have already cached get a score of `0`. - Conditions that are in the `:global` scope get a score of `2`. - Conditions that are in the `:user` or `:subject` scopes get a score of `8`. - Conditions that are in the `:normal` scope get a score of `16`. Bear helper-methods in mind when defining scopes. While the instance level cache for non-boolean values would not be shared, as long as the derived condition is shared (for example by being in the `:user` scope, rather than the `:normal` scope), helper-methods will also benefit from improved cache hits. ### Preferred scope In the example situations above (a single user visiting many countries, or a football team visiting one country), we know which is more likely to be useful, the `:subject` or the `:user` scope. We can inform the optimizer of this by setting `DeclarativePolicy.preferred_scope`. To do this, check the abilities within a block bounded by [`DeclarativePolicy.with_preferred_scope`](https://gitlab.com/gitlab-org/declarative-policy/blob/481c322a74f76c325d3ccab7f2f3cc2773e8168b/lib/declarative_policy/preferred_scope.rb#L7-13). For example: ```ruby cache = {} # preferring to run user-scoped conditions DeclarativePolicy.with_preferred_scope(:user) do itinerary.countries.all? do |c| DeclarativePolicy.policy_for(user, c, cache: cache).allowed?(:enter_country) end end # preferring to run subject-scoped conditions DeclarativePolicy.with_preferred_scope(:subject) do team.players.all? do |player| DeclarativePolicy.policy_for(player, c, cache: cache).allowed?(:enter_country) end end ``` When we set `preferred_scope`, this reduces the default score for conditions in that scope, so that they are more likely to be executed first. Instead of `8`, they are given a default score of `4`. ## Cache keys In order for an object to be cached, it should be able to identify itself with a suitable cache key. A good cache key will identify an object, without containing irrelevant information - a database `#id` is perfect, and this library defaults to calling an `#id` method on objects, falling back to `object_id`. Relying on `object_id` is not recommended since otherwise equivalent objects have different `object_id` values, and using `object_id` will not get optimal caching. All policy subjects should implement `#id` for this reason. `ActiveRecord` models with an `id` primary ID attribute do not need any extra configuration. Please see: [`DeclarativePolicy::Cache`](https://gitlab.com/gitlab-org/declarative-policy/blob/master/lib/declarative_policy/cache.rb). ## Cache invalidation Generally, cache invalidation is best avoided. It is very hard to get right, and relying on it opens you up to subtle but pernicious bugs that are hard to reproduce and debug. The best strategy is to run all permission checks upfront, before mutating any state that might change a permission computation. For instance, if you want to make a user an administrator, then check for permission **before** assigning administrator privileges. However, it isn't always possible to avoid needing to mark certain parts of the cached state as dirty (in need of re-computation). If this is needed, then you can call the `DeclarativePolicy.invalidate(cache, keys)` method. This takes an enumerable of dirty keys, and: - removes the cached condition results from the cache - marks the abilities that depend on those conditions as dirty, and in need of re-computation. The responsibility for determining which cache-keys are dirty falls on the client. You could, for example, do this by observing which keys are added to the cache (knowing that condition keys all start with `"/dp/condition/"`), or by scanning the cache for keys that match a heuristic. This method is the only place where the `#delete` method is called on the cache. If you do not call `.invalidate`, there is no need for the cache to implement `#delete`. declarative_policy-1.1.0/Gemfile0000644000004100000410000000107614153430526016705 0ustar www-datawww-data# frozen_string_literal: true source 'https://rubygems.org' # Specify your gem's dependencies in declarative-policy.gemspec gemspec group :test do gem 'rspec', '~> 3.10' gem 'rspec-parameterized', require: false gem 'pry-byebug', platforms: [:ruby] end group :development, :test do gem 'gitlab-styles', '~> 6.1.0', require: false, platforms: [:ruby] gem 'rake', '~> 12.0' gem 'benchmark', require: false gem 'rubocop', require: false end group :development, :test, :danger do gem 'gitlab-dangerfiles', '~> 1.1.0', require: false, platforms: [:ruby] end declarative_policy-1.1.0/Dangerfile0000644000004100000410000000072014153430526017370 0ustar www-datawww-data# frozen_string_literal: true require 'gitlab-dangerfiles' Gitlab::Dangerfiles.import_plugins(danger) danger.import_plugin('danger/plugins/*.rb') return if helper.release_automation? danger.import_dangerfile(path: File.join('danger', 'roulette')) anything_to_post = status_report.values.any?(&:any?) if helper.ci? && anything_to_post markdown("**If needed, you can retry the [`danger-review` job](#{ENV['CI_JOB_URL']}) that generated this comment.**") end declarative_policy-1.1.0/declarative_policy.gemspec0000644000004100000410000000302414153430526022614 0ustar www-datawww-data# frozen_string_literal: true require_relative 'lib/declarative_policy/version' Gem::Specification.new do |spec| spec.name = 'declarative_policy' spec.version = DeclarativePolicy::VERSION spec.authors = ['Jeanine Adkisson', 'Alexis Kalderimis'] spec.email = ['akalderimis@gitlab.com'] spec.summary = 'An authorization library with a focus on declarative policy definitions.' spec.description = <<~DESC This library provides an authorization framework with a declarative DSL With this library, you can write permission policies that are separate from business logic. This library is in production use at GitLab.com DESC spec.homepage = 'https://gitlab.com/gitlab-org/declarative-policy' spec.license = 'MIT' spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0') spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://gitlab.com/gitlab-org/declarative-policy' spec.metadata['changelog_uri'] = 'https://gitlab.com/gitlab-org/declarative-policy/-/blobs/master/CHANGELOG.md' # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path(__dir__)) do `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } end spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] end declarative_policy-1.1.0/benchmarks/0000755000004100000410000000000014153430526017523 5ustar www-datawww-datadeclarative_policy-1.1.0/benchmarks/repeated_invocation.rb0000755000004100000410000000206114153430526024074 0ustar www-datawww-data#!/usr/bin/env ruby -w # frozen_string_literal: true require 'declarative_policy' require 'benchmark' Dir["./spec/support/policies/*.rb"].sort.each { |f| require f } Dir["./spec/support/models/*.rb"].sort.each { |f| require f } TIMES = 1_000_000 LABEL = 'allowed?(driver, :drive_vehicle, car)' DeclarativePolicy.configure! do named_policy :global, GlobalPolicy name_transformation do |name| 'ReadmePolicy' if name == 'Vehicle' end end Benchmark.bm(LABEL.length) do |b| cache = {} valid_license = License.valid country = Country.moderate registration = Registration.new(number: 'xyz123', country: country) driver = User.new(name: 'The driver', driving_license: valid_license) owner = User.new(name: 'The Owner', trusted: [driver.name]) car = Vehicle.new(owner: owner, registration: registration) raise 'Expected to drive' unless DeclarativePolicy.policy_for(driver, car).allowed?(:drive_vehicle) b.report LABEL do TIMES.times do DeclarativePolicy.policy_for(driver, car, cache: cache).allowed?(:drive_vehicle) end end end declarative_policy-1.1.0/LICENSE.txt0000644000004100000410000000224014153430526017227 0ustar www-datawww-dataThe MIT License (MIT) Copyright (c) 2021 GitLab The original author of this library is [Jeanine Adkisson](http://jneen.net), and copyright is held by GitLab. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. declarative_policy-1.1.0/.gitlab-ci.yml0000644000004100000410000000437114153430526020047 0ustar www-datawww-dataimage: "ruby:2.7" include: - template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' - template: Security/Dependency-Scanning.gitlab-ci.yml - template: Security/License-Scanning.gitlab-ci.yml - template: Security/SAST.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml .tests: stage: test cache: paths: - vendor/ruby before_script: - ruby -v # Print out ruby version for debugging - bundle install -j $(nproc) --path vendor/ruby/$RUBY_VERSION rubocop: extends: .tests script: - bundle exec rubocop .rspec: extends: .tests script: - bundle exec rspec rspec:mri: extends: .rspec image: "ruby:$RUBY_VERSION" parallel: matrix: - RUBY_VERSION: - "2.7" - "3.0" rspec:jruby: extends: .rspec image: "bitnami/jruby:latest" variables: RUBY_VERSION: jruby rspec:truffleruby: extends: .rspec image: "flavorjones/truffleruby:latest" variables: RUBY_VERSION: truffleruby danger-review: extends: .tests needs: [] script: - > if [ -z "$DANGER_GITLAB_API_TOKEN" ]; then # Force danger to skip CI source GitLab and fallback to "local only git repo". unset GITLAB_CI # We need to base SHA to help danger determine the base commit for this shallow clone. bundle exec danger dry_run --fail-on-errors=true --verbose --base="$CI_MERGE_REQUEST_DIFF_BASE_SHA" else bundle exec danger --fail-on-errors=true --verbose fi # run security jobs on MRs # see: https://gitlab.com/gitlab-org/gitlab/-/issues/218444#note_478761991 brakeman-sast: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' gemnasium-dependency_scanning: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' bundler-audit-dependency_scanning: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' license_scanning: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' secret_detection: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'