pax_global_header00006660000000000000000000000064151550747600014524gustar00rootroot0000000000000052 comment=7cd092d88be34930f8310d6f33c965ccbdbe2268 declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/000077500000000000000000000000001515507476000227545ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitignore000066400000000000000000000003221515507476000247410ustar00rootroot00000000000000/.bundle/ /.yardoc /_yardoc/ /coverage/ /pkg/ /spec/reports/ /tmp/ # rspec failure tracking .rspec_status declarative_policy-*.gem .tool-versions # Created by benchmark-ips via `x.save!' benchmarks/.*.bm-ips declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitlab-ci.yml000066400000000000000000000033771515507476000254220ustar00rootroot00000000000000--- image: "ruby:3.3" include: - template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' - template: Security/Dependency-Scanning.gitlab-ci.yml - template: Security/SAST.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml - component: gitlab.com/gitlab-org/components/danger-review/danger-review@2.1.0 - component: gitlab.com/gitlab-org/components/gem-release/gem-release@1.1.0 .tests: stage: test cache: key: files: - Gemfile.lock paths: - vendor/ruby variables: BUNDLE_FROZEN: "true" BUNDLE_PATH: "vendor" before_script: - ruby -v # Print out ruby version for debugging - bundle install -j $(nproc) 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: - "3.0" - "3.1" - "3.2" - "3.3" - "3.4" rspec:jruby: extends: .rspec image: "jruby:latest" variables: RUBY_VERSION: jruby rspec:truffleruby: extends: .rspec image: "ghcr.io/graalvm/truffleruby:latest" variables: RUBY_VERSION: truffleruby allow_failure: true danger-review: extends: .tests # 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' secret_detection: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitlab/000077500000000000000000000000001515507476000242745ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitlab/changelog_config.yml000066400000000000000000000005161515507476000302750ustar00rootroot00000000000000--- # Settings for generating changelogs using the GitLab API. See # https://docs.gitlab.com/ee/api/repositories.html#generate-changelog-data for # more information. categories: added: Added fixed: Fixed changed: Changed deprecated: Deprecated removed: Removed security: Security performance: Performance other: Other declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitlab/merge_request_templates/000077500000000000000000000000001515507476000312215ustar00rootroot00000000000000Default.md000066400000000000000000000010761515507476000330540ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitlab/merge_request_templates## Description What does this MR do? Why was this approach chosen? %{first_multiline_commit} ## Related Issues See: `#123` ## Suggested version bump - [ ] Major (backwards incompatible changes) - [ ] Minor (backwards compatible changes) - [ ] Patch (API compatible changes) ## Checklist - [ ] Tests have been added or updated to cover any changes in behavior - [ ] This does not change the API to consume this library, or a suggested version bump has been provided - [ ] Add git trailer `Changelog: ` - [ ] No new runtime dependencies have been introduced Release.md000066400000000000000000000026141515507476000330470ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.gitlab/merge_request_templates ## Diff https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/compare/... ## Checklist - [ ] Change the `VERSION` constant to a minor version in `lib/declarative_policy/version.rb` (you might have to change the version number in the next steps according to [SemVer](https://semver.org)). - [ ] Ensure the diff link above is up-to-date. - [ ] Add release notes to the [Changelog](#changelog) section below. - [ ] Based on the diff and the release notes, update the `version.rb` according to [SemVer](https://semver.org). - [ ] Run `bundle install` to update `Gemfile.lock` ## Changelog /label ~"type::maintenance" ~"maintenance::dependency" declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.rspec000066400000000000000000000001021515507476000240620ustar00rootroot00000000000000--format documentation --color --require spec_helper --order rand declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.rubocop.yml000066400000000000000000000003751515507476000252330ustar00rootroot00000000000000inherit_from: .rubocop_todo.yml inherit_gem: gitlab-styles: - rubocop-default.yml CodeReuse/ActiveRecord: Enabled: false AllCops: TargetRubyVersion: 3.0 NewCops: enable SuggestExtensions: false RSpec/MultipleMemoizedHelpers: Max: 10 declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/.rubocop_todo.yml000066400000000000000000000031551515507476000262570ustar00rootroot00000000000000# This configuration was generated by # `rubocop --auto-gen-config` # on 2024-06-13 13:45:47 UTC using RuboCop version 1.62.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. # Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Categories, ExpectedOrder. # ExpectedOrder: module_inclusion, constants, public_class_methods, initializer, public_methods, protected_methods, private_methods Layout/ClassStructure: Exclude: - 'spec/support/models/country.rb' - 'spec/support/models/license.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: aligned, indented Layout/LineEndStringConcatenationIndentation: Exclude: - 'spec/declarative_policy/condition_spec.rb' # Offense count: 59 # Configuration parameters: EnforcedStyle, IgnoreSharedExamples. # SupportedStyles: always, named_only RSpec/NamedSubject: Exclude: - 'spec/declarative_policy/rule_spec.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). Style/RedundantParentheses: Exclude: - 'lib/declarative_policy/base.rb' # Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: AllowedMethods, AllowedPatterns. Style/ReturnNilInPredicateMethodDefinition: Exclude: - 'lib/declarative_policy/rule.rb' declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/CHANGELOG.md000066400000000000000000000012441515507476000245660ustar00rootroot00000000000000Starting from version 2.0, changelog entries are tracked via https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/releases 2.0.0: - Drop explicit support for Ruby 2.6 and 2.7 by removing those versions from the CI matrix. These Ruby versions are now past EOL. - Rename default condition scope name to `:user_and_subject` - Clarify the use of `User` and `Subject` in README and documentation - Update `ruby-git` in Gemfile.lock 1.1.1: - Define development dependencies 1.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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/CODE_OF_CONDUCT.md000066400000000000000000000062411515507476000255560ustar00rootroot00000000000000# 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/CONTRIBUTING.md000066400000000000000000000042421515507476000252070ustar00rootroot00000000000000## 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/Dangerfile000066400000000000000000000003131515507476000247340ustar00rootroot00000000000000# frozen_string_literal: true require 'gitlab-dangerfiles' Gitlab::Dangerfiles.for_project(self) do |gitlab_dangerfiles| gitlab_dangerfiles.import_plugins gitlab_dangerfiles.import_dangerfiles end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/Gemfile000066400000000000000000000003011515507476000242410ustar00rootroot00000000000000# frozen_string_literal: true source 'https://rubygems.org' # Specify your gem's dependencies in declarative-policy.gemspec gemspec group :test do gem 'pry-byebug', platforms: [:ruby] end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/Gemfile.lock000066400000000000000000000126361515507476000252060ustar00rootroot00000000000000PATH remote: . specs: declarative_policy (2.1.0) GEM remote: https://rubygems.org/ specs: activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) minitest (>= 5.1) mutex_m tzinfo (~> 2.0) addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) base64 (0.2.0) benchmark-ips (2.13.0) bigdecimal (3.1.8) bigdecimal (3.1.8-java) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) byebug (11.1.3) claide (1.1.0) claide-plugins (0.9.2) cork nap open4 (~> 1.3) coderay (1.1.3) colored2 (3.1.2) concurrent-ruby (1.3.3) connection_pool (2.4.1) cork (0.3.0) colored2 (~> 3.1) csv (3.3.0) danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) claide-plugins (>= 0.9.2) colored2 (>= 3.1, < 5) cork (~> 0.1) faraday (>= 0.9.0, < 3.0) faraday-http-cache (~> 2.0) git (>= 1.13, < 3.0) kramdown (>= 2.5.1, < 3.0) kramdown-parser-gfm (~> 1.0) octokit (>= 4.0) pstore (~> 0.1) terminal-table (>= 1, < 5) danger-gitlab (8.0.0) danger gitlab (~> 4.2, >= 4.2.0) debug_inspector (1.2.0) diff-lcs (1.5.1) drb (2.2.1) faraday (2.9.1) faraday-net_http (>= 2.0, < 3.2) faraday-http-cache (2.5.1) faraday (>= 0.8) faraday-net_http (3.1.0) net-http ffi (1.17.0-java) git (2.3.3) activesupport (>= 5.0) addressable (~> 2.8) process_executer (~> 1.1) rchardet (~> 1.8) gitlab (4.19.0) httparty (~> 0.20) terminal-table (>= 1.5.1) gitlab-dangerfiles (3.13.0) danger (>= 8.4.5) danger-gitlab (>= 8.0.0) rake gitlab-styles (12.0.1) rubocop (~> 1.62.1) rubocop-factory_bot (~> 2.25.1) rubocop-graphql (~> 1.5.0) rubocop-performance (~> 1.20.2) rubocop-rails (~> 2.24.0) rubocop-rspec (~> 2.27.1) httparty (0.22.0) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) i18n (1.14.5) concurrent-ruby (~> 1.0) json (2.7.2) json (2.7.2-java) kramdown (2.5.1) rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.3) method_source (1.1.0) mini_mime (1.1.5) minitest (5.23.1) multi_xml (0.6.0) mutex_m (0.2.0) nap (1.1.0) net-http (0.4.1) uri octokit (9.1.0) faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) parallel (1.25.1) parser (3.3.3.0) ast (~> 2.4.1) racc proc_to_ast (0.2.0) parser rouge unparser process_executer (1.1.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) pry (0.14.2-java) coderay (~> 1.1) method_source (~> 1.0) spoon (~> 0.0) pry-byebug (3.10.1) byebug (~> 11.0) pry (>= 0.13, < 0.15) pstore (0.2.0) public_suffix (5.0.5) racc (1.8.0) racc (1.8.0-java) rack (3.1.3) rainbow (3.1.1) rake (12.3.3) rchardet (1.8.0) regexp_parser (2.9.2) rexml (3.4.1) rouge (4.2.1) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) rspec-core (3.13.0) rspec-support (~> 3.13.0) rspec-expectations (3.13.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-parameterized (1.0.2) rspec-parameterized-core (< 2) rspec-parameterized-table_syntax (< 2) rspec-parameterized-core (1.0.1) parser proc_to_ast (>= 0.2.0) rspec (>= 2.13, < 4) unparser rspec-parameterized-table_syntax (1.0.1) binding_of_caller rspec-parameterized-core (< 2) rspec-support (3.13.1) rubocop (1.62.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.31.3) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) rubocop-factory_bot (2.25.1) rubocop (~> 1.41) rubocop-graphql (1.5.1) rubocop (>= 0.90, < 2) rubocop-performance (1.20.2) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.30.0, < 2.0) rubocop-rails (2.24.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) rubocop-rspec (2.27.1) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) spoon (0.0.6) ffi terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) unparser (0.6.13) diff-lcs (~> 1.3) parser (>= 3.3.0) uri (0.13.0) PLATFORMS java ruby DEPENDENCIES benchmark-ips (~> 2.12) declarative_policy! gitlab-dangerfiles (~> 3.8) gitlab-styles (~> 12.0) pry-byebug rake (~> 12.0) rspec (~> 3.10) rspec-parameterized (~> 1.0) BUNDLED WITH 2.5.11 declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/LICENSE.txt000066400000000000000000000022401515507476000245750ustar00rootroot00000000000000The 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/README.md000066400000000000000000000176061515507476000242450ustar00rootroot00000000000000# `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: ```plain $ bundle install ``` Or install it yourself as: ```plain $ gem install declarative_policy ``` ## Example ```ruby require 'declarative_policy' class User attr_reader :name def initialize(name:) @name = name end end class Vehicle def initialize(owner:, trusted: []) @owner = owner @trusted = trusted end def owner?(user) @owner.name == user.name end def trusted?(user) @owner.name == user.name || @trusted.detect { |t| t.name == user.name } end end class VehiclePolicy < DeclarativePolicy::Base condition(:owns) { @subject.owner?(@user) } condition(:trusted) { @subject.trusted?(@user) } rule { owns }.enable :sell_vehicle rule { trusted }.enable :drive_vehicle end jack = User.new(name: 'jack') jill = User.new(name: 'jill') jacks_vehicle = Vehicle.new(owner: jack, trusted: [jill]) jills_vehicle = Vehicle.new(owner: jill, trusted: [jack]) puts "Jack can drive Jack's vehicle? -> #{DeclarativePolicy.policy_for(jack, jacks_vehicle).can?(:drive_vehicle)}" puts "Jack can drive Jill's vehicle? -> #{DeclarativePolicy.policy_for(jack, jills_vehicle).can?(:drive_vehicle)}" puts "Jack can sell Jack's vehicle? -> #{DeclarativePolicy.policy_for(jack, jacks_vehicle).can?(:sell_vehicle)}" puts "Jack can sell Jill's vehicle? -> #{DeclarativePolicy.policy_for(jack, jills_vehicle).can?(:sell_vehicle)}" ``` ```plain $ ruby example.rb Jack can drive Jack's vehicle? -> true Jack can drive Jill's vehicle? -> true Jack can sell Jack's vehicle? -> true Jack can sell Jill's vehicle? -> false ``` ## 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: ```plain User Predicate [Subject] ``` Renaming `User` to `Actor` and `Subject` to `Resource` is discussed in [this issue](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/issues/6). 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). ## Additional Reading Material More details on policies and custom roles can be found in the following pages: - [Development Process for the DeclarativePolicy framework](https://docs.gitlab.com/ee/development/policies.html) - [Custom Roles docs](https://docs.gitlab.com/ee/development/permissions/custom_roles.html) ## Contributing Bug reports and merge requests are welcome on GitLab at https://gitlab.com/gitlab-org/ruby/gems/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/). ## Release process We release `declarative_policy` on an ad-hoc basis. There is no regularity to when we release, we just release when we make a change - no matter the size of the change. To release a new version: 1. Create a Merge Request. 1. Use Merge Request template [Release.md](https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/blob/main/.gitlab/merge_request_templates/Release.md). 1. Follow the instructions. 1. After the Merge Request has been merged, a new gem version is [published automatically](https://gitlab.com/gitlab-org/components/gem-release). 1. Once the new gem version is visible on [RubyGems.org](https://rubygems.org/gems/declarative_policy), it is recommended to update [GitLab's `Gemfile`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/Gemfile) to bump the `declarative_policy` Ruby gem to the new version also. ## 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://gitlab.com/gitlab-org/ruby/gems/declarative-policy/blob/main/CODE_OF_CONDUCT.md). declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/Rakefile000066400000000000000000000002211515507476000244140ustar00rootroot00000000000000# frozen_string_literal: true require 'bundler/gem_tasks' require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) task default: :spec declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/benchmarks/000077500000000000000000000000001515507476000250715ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/benchmarks/repeated_invocation.rb000077500000000000000000000027371515507476000314540ustar00rootroot00000000000000#!/usr/bin/env ruby -w # frozen_string_literal: true require 'declarative_policy' require 'benchmark/ips' Dir["./spec/support/policies/*.rb"].each { |f| require f } Dir["./spec/support/models/*.rb"].each { |f| require f } DeclarativePolicy.configure! do named_policy :global, GlobalPolicy name_transformation do |name| 'ReadmePolicy' if name == 'Vehicle' end end 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) branch = `git rev-parse --abbrev-ref HEAD`.chomp Benchmark.ips do |x| x.report "cached - known ability - #{branch}" do DeclarativePolicy.policy_for(driver, car, cache: cache).allowed?(:drive_vehicle) end x.report "cached - unknown ability - #{branch}" do DeclarativePolicy.policy_for(driver, car, cache: cache).allowed?(:unknown_ability) end x.report "uncached - known ability - #{branch}" do DeclarativePolicy.policy_for(driver, car).allowed?(:drive_vehicle) end x.report "uncached - unknown ability - #{branch}" do DeclarativePolicy.policy_for(driver, car).allowed?(:unknown_ability) end x.compare! x.save! 'benchmarks/.repeated_invocation.bm-ips' end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/declarative-policy.gemspec000066400000000000000000000037031515507476000301040ustar00rootroot00000000000000# 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 = ['group::authorization'] spec.email = ['engineering@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/ruby/gems/declarative-policy' spec.license = 'MIT' spec.required_ruby_version = Gem::Requirement.new('>= 3.0.0') spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://gitlab.com/gitlab-org/ruby/gems/declarative-policy' spec.metadata['changelog_uri'] = 'https://gitlab.com/gitlab-org/ruby/gems/declarative-policy/-/releases' spec.metadata['rubygems_mfa_required'] = 'false' # Specify which files should be added to the gem when it is released. spec.files = Dir.chdir(File.expand_path(__dir__)) do %w[ *.gemspec lib/**/*.rb *.{md,txt} doc/**/* ].flat_map { |pattern| Dir.glob(pattern) } end spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] # Development dependencies: spec.add_development_dependency 'benchmark-ips', '~> 2.12' spec.add_development_dependency 'gitlab-dangerfiles', '~> 3.8' spec.add_development_dependency 'gitlab-styles', '~> 12.0' spec.add_development_dependency 'pry-byebug' spec.add_development_dependency 'rake', '~> 12.0' spec.add_development_dependency 'rspec', '~> 3.10' spec.add_development_dependency 'rspec-parameterized', '~> 1.0' end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/doc/000077500000000000000000000000001515507476000235215ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/doc/caching.md000066400000000000000000000316771515507476000254550ustar00rootroot00000000000000# 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, subject)` with identical `user` and `subject` 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/ruby/gems/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 `:user_and_subject` 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 `:user_and_subject` 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/ruby/gems/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/ruby/gems/declarative-policy/blob/main/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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/doc/configuration.md000066400000000000000000000045321515507476000267160ustar00rootroot00000000000000# 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/doc/defining-policies.md000066400000000000000000000220361515507476000274360ustar00rootroot00000000000000# 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, subject) opts = { cache: Cache.current_cache } # re-using a cache between checks eliminates duplication of work policy = DeclarativePolicy.policy_for(user, subject, 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, subject)`. `@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. `subject` in `allowed?(user, ability, subject)`. `@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 ``` #### `prevent_all` To prevent all abilities at once, use `prevent_all`: ```ruby rule { banned }.prevent_all ``` This is equivalent to adding a `prevent` for every ability in the policy. It is commonly used to deny all access when a precondition fails (e.g. a user is suspended or a resource is locked). #### `prevent_all` with exceptions To prevent all abilities **except** specific ones, pass a block with `except` declarations: ```ruby rule { suspended }.prevent_all do except :read except :appeal_suspension end ``` Multiple abilities can also be listed in a single `except` call: ```ruby rule { suspended }.prevent_all do except :read, :list, :appeal_suspension end ``` Excepted abilities are excluded from the blanket prevent and follow normal enable/prevent evaluation. Non-excepted abilities are prevented when the rule's condition holds, regardless of any `enable` rules. 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. #### Overrides It can be useful to declare that the given abilities should not be read from delegates. This declaration is useful if you have an ability you want to define differently in a policy than in a delegated policy, but you still want to delegate all other abilities. ```ruby delegate { @subject.parent } overrides :drive_car, :watch_tv ``` NOTE: Rules with `prevent_all` present in the delegated policy are properly not used during overridden abilities evaluation. #### Delegated conditions When named delegates are defined, their [conditions](#conditions) can be referenced in [rules](#rules) using bare words. Given a registration policy: ```ruby class RegistrationPolicy < DeclarativePolicy::Base condition(:valid) { @subject.valid_for?(@user.current_location) } end ``` The vehicle policy can reference the `:valid` condition as follows: ```ruby delegate(:registration) { @subject.registration } rule { registration.valid }.enable :drive_vehicle ``` declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/doc/optimization.md000066400000000000000000000233231515507476000265740ustar00rootroot00000000000000# 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/ruby/gems/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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/000077500000000000000000000000001515507476000235225ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy.rb000066400000000000000000000061751515507476000277220ustar00rootroot00000000000000# 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/prevent_all_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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/000077500000000000000000000000001515507476000273645ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/base.rb000066400000000000000000000271721515507476000306340ustar00rootroot00000000000000# 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, filtering out any global actions that have excepted this ability. def configuration_for(ability) applicable_globals = global_actions.filter_map do |(action, rule, exceptions)| next if exceptions&.include?(ability) [action, rule] end ability_map.actions(ability) + applicable_globals 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 { @subject.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. # The optional `except` parameter is a Set of abilities # that should be excluded from the blanket prevent. def prevent_all_when(rule, except: nil) own_global_actions << [:prevent, rule, except] 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) return false if abilities.empty? 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. # Only considers global actions with no exceptions (a prevent_all with # exceptions cannot fully ban, since the excepted abilities may still pass). def banned? global_steps = self.class.global_actions.filter_map do |(action, rule, exceptions)| next if exceptions && !exceptions.empty? Step.new(self, rule, action) end return false if global_steps.empty? !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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/cache.rb000066400000000000000000000013551515507476000307600ustar00rootroot00000000000000# 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 condition.rb000066400000000000000000000077011515507476000316250ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# 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 VALID_SCOPES = %i[user_and_subject user subject global].freeze ALLOWED_SCOPES = VALID_SCOPES + %i[normal] def initialize(name, opts = {}, &compute) @name = name @compute = compute @scope = fetch_scope(opts) @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 private def fetch_scope(options) result = options.fetch(:scope, :user_and_subject) if result == :normal warn "[DEPRECATION] `:normal` is deprecated and will be removed in 2.0. Please use new name `:user_and_subject`" result = :user_and_subject end raise "Invalid scope #{result}. Allowed values: #{VALID_SCOPES.inspect}" unless ALLOWED_SCOPES.include?(result) result 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 == :user_and_subject # 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 :global then "/dp/condition/#{@condition.key}" when :user then "/dp/condition/#{@condition.key}/#{user_key}" when :subject then "/dp/condition/#{@condition.key}/#{subject_key}" else "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}" end end private def user_key Cache.user_key(@context.user) end def subject_key Cache.subject_key(@context.subject) end end end configuration.rb000066400000000000000000000017141515507476000325040ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# 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 delegate_dsl.rb000066400000000000000000000007471515507476000322560ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# 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 nil_policy.rb000066400000000000000000000002721515507476000317740ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# frozen_string_literal: true # Default policy definition for nil values module DeclarativePolicy class NilPolicy < DeclarativePolicy::Base rule { default }.prevent_all end end policy_dsl.rb000066400000000000000000000026061515507476000317770ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# 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(&block) if block dsl = PreventAllDsl.new dsl.instance_eval(&block) @context_class.prevent_all_when(@rule, except: dsl.exceptions) else @context_class.prevent_all_when(@rule) end end def method_missing(msg, ...) return super unless @context_class.respond_to?(msg) @context_class.__send__(msg, ...) # rubocop: disable GitlabSecurity/PublicSend end def respond_to_missing?(msg) @context_class.respond_to?(msg) || super end end end preferred_scope.rb000066400000000000000000000013031515507476000327760ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# 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 prevent_all_dsl.rb000066400000000000000000000007241515507476000330120ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy# frozen_string_literal: true require 'set' module DeclarativePolicy # A small DSL class used within a prevent_all { ... } block # to capture exception abilities. # # Usage: # rule { some_condition }.prevent_all do # except :read # except :list # end class PreventAllDsl attr_reader :exceptions def initialize @exceptions = Set.new end def except(*abilities) @exceptions.merge(abilities) end end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/rule.rb000066400000000000000000000163011515507476000306610ustar00rootroot00000000000000# 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/rule_dsl.rb000066400000000000000000000017711515507476000315300ustar00rootroot00000000000000# 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/runner.rb000066400000000000000000000144521515507476000312300ustar00rootroot00000000000000# 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 @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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/step.rb000066400000000000000000000054441515507476000306730ustar00rootroot00000000000000# 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-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/lib/declarative_policy/version.rb000066400000000000000000000001201515507476000313670ustar00rootroot00000000000000# frozen_string_literal: true module DeclarativePolicy VERSION = '2.1.0' end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/000077500000000000000000000000001515507476000237065ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy/000077500000000000000000000000001515507476000275505ustar00rootroot00000000000000base_cache_collision_spec.rb000066400000000000000000000023041515507476000351370ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true RSpec.describe DeclarativePolicy::Base do context 'when a condition is declared in two classes' do let(:rules_a) do Class.new(described_class) do condition(:suitable) { subject == :a } rule { suitable }.enable(:ok) end end let(:rules_b) do Class.new(described_class) do condition(:suitable) { subject == :b } rule { suitable }.enable(:ok) end end let(:cache) { {} } def policy(rules, object) rules.new(nil, object, cache: cache) end it 'is does not overwrite the cache entries of conditions with the same name' do expect(policy(rules_a, :foo)).not_to be_allowed(:ok) expect(policy(rules_b, :foo)).not_to be_allowed(:ok) expect(policy(rules_a, :a)).to be_allowed(:ok) expect(policy(rules_b, :a)).not_to be_allowed(:ok) expect(policy(rules_a, :b)).not_to be_allowed(:ok) expect(policy(rules_b, :b)).to be_allowed(:ok) end it 'writes separate cache entries for each condition' do expect do policy(rules_a, :foo).allowed?(:ok) policy(rules_b, :foo).allowed?(:ok) end.to change { cache.size }.by(2) end end end condition_spec.rb000066400000000000000000000010661515507476000330210ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true RSpec.describe DeclarativePolicy::Condition do subject(:condition) { described_class.new(name, opts, &compute_block) } let(:name) { 'condition_name' } let(:opts) { {} } let(:compute_block) { proc {} } describe 'deprecations' do let(:opts) { { scope: :normal } } it 'deprecates :normal scope' do notice = "[DEPRECATION] `:normal` is deprecated and will be removed in 2.0. " \ "Please use new name `:user_and_subject`\n" expect { condition }.to output(notice).to_stderr end end end overrides_spec.rb000066400000000000000000000057051515507476000330410ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true require 'rspec-parameterized' RSpec.describe 'DeclarativePolicy overrides' do let(:foo_policy) do Class.new(DeclarativePolicy::Base) do condition(:foo_prop_cond) { @subject.foo_prop } rule { foo_prop_cond }.policy do enable :common_ability enable :foo_prop_ability end end end let(:bar_policy) do Class.new(DeclarativePolicy::Base) do delegate { @subject.foo } overrides :common_ability condition(:bar_prop_cond) { @subject.bar_prop } rule { bar_prop_cond }.policy do enable :common_ability enable :bar_prop_ability end rule { bar_prop_cond & can?(:foo_prop_ability) }.policy do enable :combined_ability end end end before do stub_const('Foo', Struct.new(:foo_prop)) stub_const('FooPolicy', foo_policy) stub_const('Bar', Struct.new(:foo, :bar_prop)) stub_const('BarPolicy', bar_policy) end where(:foo_prop, :bar_prop) do [ [true, true], [true, false], [false, true], [false, false] ] end with_them do let(:foo) { Foo.new(foo_prop) } let(:bar) { Bar.new(foo, bar_prop) } it 'determines the correct bar_prop_ability (non-delegated) permissions for bar' do policy = DeclarativePolicy.policy_for(nil, bar) expect(policy.allowed?(:bar_prop_ability)).to eq(bar_prop) end it 'determines the correct foo_prop (non-overridden) permissions for bar' do policy = DeclarativePolicy.policy_for(nil, bar) expect(policy.allowed?(:foo_prop_ability)).to eq(foo_prop) end it 'determines the correct common_ability (overridden) permissions for bar' do policy = DeclarativePolicy.policy_for(nil, bar) expect(policy.allowed?(:common_ability)).to eq(bar_prop) end it 'determines the correct common_ability permissions for foo' do policy = DeclarativePolicy.policy_for(nil, foo) expect(policy.allowed?(:common_ability)).to eq(foo_prop) end it 'allows combinations of overridden and inherited values' do policy = DeclarativePolicy.policy_for(nil, bar) expect(policy.allowed?(:combined_ability)).to eq(foo_prop && bar_prop) end context 'with prevent_all in the delegated policy' do let(:foo_policy) do Class.new(DeclarativePolicy::Base) do condition(:foo_prop_cond) { @subject.foo_prop } rule { ~foo_prop_cond }.policy { prevent_all } rule { foo_prop_cond }.policy { enable :common_ability } end end it 'determines the correct common_ability permissions for foo' do policy = DeclarativePolicy.policy_for(nil, foo) expect(policy.allowed?(:common_ability)).to eq(foo_prop) end it 'determines the correct common_ability (overridden) permissions for bar' do policy = DeclarativePolicy.policy_for(nil, bar) expect(policy.allowed?(:common_ability)).to eq(bar_prop) end end end end prevent_all_with_exceptions_spec.rb000066400000000000000000000164471515507476000366530ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true RSpec.describe 'prevent_all with exceptions' do let(:policy_class) do Class.new(DeclarativePolicy::Base) do condition(:blocked) { @subject.blocked } rule { blocked }.prevent_all do except :read except :list end rule { default }.policy do enable :read enable :list enable :edit enable :admin end end end before do stub_const('Resource', Struct.new(:blocked)) stub_const('ResourcePolicy', policy_class) end subject(:policy) { DeclarativePolicy.policy_for(nil, policy_subject) } context 'when the subject is blocked' do let(:policy_subject) { Resource.new(true) } it 'allows excepted abilities' do expect(policy).to be_allowed(:read) expect(policy).to be_allowed(:list) end it 'prevents non-excepted abilities' do expect(policy).not_to be_allowed(:edit) expect(policy).not_to be_allowed(:admin) end it 'is not fully banned (excepted abilities can still pass)' do expect(policy.banned?).to be false end end context 'when the subject is not blocked' do let(:policy_subject) { Resource.new(false) } it 'allows all abilities' do expect(policy).to be_allowed(:read) expect(policy).to be_allowed(:list) expect(policy).to be_allowed(:edit) expect(policy).to be_allowed(:admin) end end context 'with multiple except calls on one line' do let(:policy_class) do Class.new(DeclarativePolicy::Base) do condition(:blocked) { @subject.blocked } rule { blocked }.prevent_all do except :read, :list end rule { default }.policy do enable :read enable :list enable :edit end end end let(:thing) { Resource.new(true) } subject(:policy) { DeclarativePolicy.policy_for(nil, thing) } it 'excepts all listed abilities' do expect(policy).to be_allowed(:read) expect(policy).to be_allowed(:list) end it 'prevents non-excepted abilities' do expect(policy).not_to be_allowed(:edit) end end context 'with delegated policies' do let(:delegating_policy) do Class.new(DeclarativePolicy::Base) do delegate { @subject.parent } condition(:delegating_active) { @subject.active } rule { delegating_active }.policy do enable :comment end end end before do stub_const('Delegate', Struct.new(:parent, :active)) stub_const('DelegatePolicy', delegating_policy) end context 'when the delegated subject is blocked' do let(:parent) { Resource.new(true) } let(:policy_subject) { Delegate.new(parent, true) } it 'allows excepted abilities from the delegated policy' do expect(policy).to be_allowed(:read) end it 'prevents non-excepted abilities from the delegated policy' do expect(policy).not_to be_allowed(:edit) expect(policy).not_to be_allowed(:admin) end it 'prevents abilities defined on the delegating that are not excepted' do expect(policy).not_to be_allowed(:comment) end end context 'when the delegated subject is not blocked' do let(:parent) { Resource.new(false) } let(:policy_subject) { Delegate.new(parent, true) } it 'allows all delegated abilities' do expect(policy).to be_allowed(:read) expect(policy).to be_allowed(:edit) expect(policy).to be_allowed(:admin) end it 'allows abilities directly enabled' do expect(policy).to be_allowed(:comment) end end context 'with overrides on the delegating policy' do let(:delegating_policy) do Class.new(DeclarativePolicy::Base) do delegate { @subject.parent } overrides :edit condition(:active) { @subject.active } rule { active }.policy do enable :edit enable :comment end end end let(:parent) { Resource.new(true) } let(:policy_subject) { Delegate.new(parent, true) } it 'allows excepted abilities from the delegated policy' do expect(policy).to be_allowed(:read) end it 'allows overridden abilities regardless of delegated prevent_all' do expect(policy).to be_allowed(:edit) end it 'prevents non-excepted, non-overridden abilities from delegated policy' do expect(policy).not_to be_allowed(:admin) expect(policy).not_to be_allowed(:comment) end end end context 'with inherited policies' do let(:sub_policy) do Class.new(policy_class) do condition(:premium) { @subject.premium } rule { premium }.policy do enable :export end end end before do stub_const('SubResource', Struct.new(:blocked, :premium)) stub_const('SubResourcePolicy', sub_policy) end context 'when the subject is blocked' do let(:policy_subject) { SubResource.new(true, true) } it 'inherits prevent_all exceptions from the parent policy' do expect(policy).to be_allowed(:read) end it 'prevents non-excepted inherited abilities' do expect(policy).not_to be_allowed(:edit) expect(policy).not_to be_allowed(:admin) end it 'prevents abilities defined in the subclass' do expect(policy).not_to be_allowed(:export) end end context 'when the subject is not blocked' do let(:policy_subject) { SubResource.new(false, true) } it 'allows all abilities' do expect(policy).to be_allowed(:read) expect(policy).to be_allowed(:edit) expect(policy).to be_allowed(:admin) expect(policy).to be_allowed(:export) end end context 'when the subclass adds its own prevent_all with different exceptions' do let(:sub_policy) do Class.new(policy_class) do condition(:premium) { @subject.premium } condition(:restricted) { @subject.blocked } rule { restricted }.prevent_all do except :read, :edit end rule { premium }.policy do enable :export end end end let(:policy_subject) { SubResource.new(true, true) } it 'combines exceptions from both parent and subclass prevent_all rules' do # :read is excepted by both parent and subclass expect(policy).to be_allowed(:read) # :edit is excepted by subclass but not by parent. The parent still prevents it expect(policy).not_to be_allowed(:edit) end it 'prevents abilities not excepted by either' do expect(policy).not_to be_allowed(:admin) expect(policy).not_to be_allowed(:export) end end end context 'when prevent_all has no block' do let(:policy_class) do Class.new(DeclarativePolicy::Base) do condition(:blocked) { @subject.blocked } rule { blocked }.prevent_all rule { default }.policy do enable :read enable :edit end end end let(:policy_subject) { Resource.new(true) } it 'prevents all abilities' do expect(policy).not_to be_allowed(:read) expect(policy).not_to be_allowed(:edit) end it 'is fully banned' do expect(policy.banned?).to be true end end end rule_dsl_spec.rb000066400000000000000000000173401515507476000326460ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true # Also includes tests for some combining methods on `Rule`. # This defines the specification for what can appear in a `rule { ... }` block. RSpec.describe DeclarativePolicy::RuleDsl do def temperature(value) policy.new(nil, value) end let(:low_range) { (-200..-1) } let(:mid_range) { (0..100) } let(:high_range) { (101..200) } let(:base_policy) do Class.new(DeclarativePolicy::Base) do condition(:zero) { value.zero? } condition(:even) { value.even? } condition(:odd) { value.odd? } condition(:high) { value > 100 } condition(:low) { value < 0 } condition(:always) { true } condition(:never) { false } rule { ~high & ~low }.enable :liquid_water def value @subject end end end it 'cannot refer to the subject directly' do expect do Class.new(base_policy) do rule { @subject.positive? }.enable :positive end end.to raise_error(NoMethodError) end it 'cannot refer to other policy methods' do policy = Class.new(base_policy) do rule { value == 1 }.enable :one end.new(nil, 1) expect { policy.allowed?(:one) }.to raise_error(NoMethodError) expect { policy.allowed?(:liquid_water) }.not_to raise_error end describe 'use of bare words' do let(:policy) do Class.new(base_policy) do rule { high }.enable :steam end end it 'refers to conditions by name' do expect(low_range.map { |t| temperature(t) }).to all(be_disallowed(:steam)) expect(mid_range.map { |t| temperature(t) }).to all(be_disallowed(:steam)) expect(high_range.map { |t| temperature(t) }).to all(be_allowed(:steam)) end end describe 'delegate' do let(:succ) { Struct.new(:value) } let(:policy) do Class.new(base_policy) do delegate(:succ) { Succ.new(@subject.succ) } rule { delegate(:succ, :even) }.enable :odd end end # this is needed to break infinite recursion # policies are eager in their delegations! let(:succ_policy) do Class.new(base_policy) do def value @subject.value end end end before do DeclarativePolicy.configure do name_transformation { |name| 'SuccPolicy' if name == 'Succ' } end stub_const('SuccPolicy', succ_policy) stub_const('Succ', succ) end it 'refers to a condition on a delegate by name' do expect(temperature(9)).to be_allowed(:odd) expect(temperature(10)).not_to be_allowed(:odd) end context 'with bare words rule' do let(:policy) do Class.new(base_policy) do delegate(:succ) { Succ.new(@subject.succ) } rule { succ.even }.enable :odd end end it 'refers to a condition on a delegate by name' do expect(temperature(9)).to be_allowed(:odd) expect(temperature(10)).not_to be_allowed(:odd) end end end describe 'cond' do let(:policy) do Class.new(base_policy) do rule { cond(:high) }.enable :steam end end it 'refers to conditions by name' do expect(low_range.map { |t| temperature(t) }).to all(be_disallowed(:steam)) expect(mid_range.map { |t| temperature(t) }).to all(be_disallowed(:steam)) expect(high_range.map { |t| temperature(t) }).to all(be_allowed(:steam)) end end describe 'use of |' do let(:policy) do Class.new(base_policy) do rule { high | low }.enable :unsurvivable end end it 'requires one condition' do expect(low_range.map { |t| temperature(t) }).to all(be_allowed(:unsurvivable)) expect(mid_range.map { |t| temperature(t) }).to all(be_disallowed(:unsurvivable)) expect(high_range.map { |t| temperature(t) }).to all(be_allowed(:unsurvivable)) end end describe 'any?' do let(:policy) do Class.new(base_policy) do rule { any?(high, low) }.enable :unsurvivable end end it 'requires one condition' do expect(low_range.map { |t| temperature(t) }).to all(be_allowed(:unsurvivable)) expect(mid_range.map { |t| temperature(t) }).to all(be_disallowed(:unsurvivable)) expect(high_range.map { |t| temperature(t) }).to all(be_allowed(:unsurvivable)) end end describe 'none?' do let(:policy) do Class.new(base_policy) do rule { none?(high, low) }.enable :survivable end end it 'requires neither condition' do expect(low_range.map { |t| temperature(t) }).to all(be_disallowed(:survivable)) expect(mid_range.map { |t| temperature(t) }).to all(be_allowed(:survivable)) expect(high_range.map { |t| temperature(t) }).to all(be_disallowed(:survivable)) end end describe 'all?' do let(:policy) do Class.new(base_policy) do rule { all?(~high, ~low) }.enable :liquid_water rule { all?(~high, ~low, even) }.enable :even_liquid rule { all?(~high, ~low, even, never) }.enable :quodlibet end end it 'requires both conditions' do expect(low_range.map { |t| temperature(t) }).to all(be_disallowed(:liquid_water, :even_liquid)) expect(high_range.map { |t| temperature(t) }).to all(be_disallowed(:liquid_water, :even_liquid)) expect(temperature(1)).to be_allowed(:liquid_water) expect(temperature(1)).not_to be_allowed(:even_liquid) expect(temperature(2)).to be_allowed(:liquid_water) expect(temperature(2)).to be_allowed(:even_liquid) expect(temperature(100)).to be_allowed(:liquid_water) expect(temperature(100)).to be_allowed(:even_liquid) (low_range.to_a + mid_range.to_a + high_range.to_a).each do |i| expect(temperature(i)).not_to be_allowed(:quodlibet) end end end describe 'use of &' do let(:policy) do Class.new(base_policy) do rule { ~high & ~low }.enable :liquid_water rule { ~high & ~low & even }.enable :even_liquid rule { ~high & ~low & even & never }.enable :quodlibet end end it 'requires both conditions' do expect(low_range.map { |t| temperature(t) }).to all(be_disallowed(:liquid_water, :even_liquid)) expect(high_range.map { |t| temperature(t) }).to all(be_disallowed(:liquid_water, :even_liquid)) expect(temperature(1)).to be_allowed(:liquid_water) expect(temperature(1)).not_to be_allowed(:even_liquid) expect(temperature(2)).to be_allowed(:liquid_water) expect(temperature(2)).to be_allowed(:even_liquid) expect(temperature(100)).to be_allowed(:liquid_water) expect(temperature(100)).to be_allowed(:even_liquid) (low_range.to_a + mid_range.to_a + high_range.to_a).each do |i| expect(temperature(i)).not_to be_allowed(:quodlibet) end end end describe '~' do let(:policy) do Class.new(base_policy) do rule { ~even }.enable :uneven rule { ~can?(:uneven) }.enable :ununeven end end it 'is the inverse of the condition' do (-10..10).each do |i| if i.even? expect(temperature(i)).to be_allowed(:ununeven) expect(temperature(i)).not_to be_allowed(:uneven) else expect(temperature(i)).not_to be_allowed(:ununeven) expect(temperature(i)).to be_allowed(:uneven) end end end end describe 'can?' do let(:policy) do Class.new(base_policy) do rule { ~high & ~low }.enable :liquid_water rule { can?(:liquid_water) }.enable :other_ability end end it 'infers the state from other rules' do (low_range.to_a + mid_range.to_a + high_range.to_a).each do |i| t = temperature(i) matcher = t.allowed?(:liquid_water) ? be_allowed(:other_ability) : be_disallowed(:other_ability) expect(t).to match matcher end end end end rule_spec.rb000066400000000000000000000350071515507476000320040ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true RSpec.describe DeclarativePolicy::Rule do let(:ctx) { instance_double(DeclarativePolicy::Base) } let(:pass_value) { true } let(:cache_state) { true } let(:score) { 17 } let(:delegated_policies) { {} } let(:manifest_condition) do instance_double(DeclarativePolicy::ManifestCondition, pass?: pass_value, cached?: cache_state, score: score) end before do allow(ctx).to receive(:condition).with(:foo).and_return(manifest_condition) allow(ctx).to receive(:delegated_policies).and_return(delegated_policies) end describe 'combinators' do let(:x) { DeclarativePolicy::Rule::Condition.new(:x) } let(:y) { DeclarativePolicy::Rule::Condition.new(:y) } describe '#or' do it 'builds an Or node' do expect(x.or(y).repr).to eq 'any?(x, y)' expect((x | y).repr).to eq 'any?(x, y)' end end describe '#and' do it 'builds an And node' do expect(x.and(y).repr).to eq 'all?(x, y)' expect((x & y).repr).to eq 'all?(x, y)' end end describe '#negate' do it 'builds a Not node' do expect(x.negate.repr).to eq '~x' expect((~y).repr).to eq '~y' end end end describe DeclarativePolicy::Rule::Condition do subject { described_class.new(:foo) } describe '#pass?' do it 'delegates to the underlying condition' do expect(subject.pass?(ctx)).to eq pass_value end end describe '#cached_pass?' do context 'when cached' do it 'calls pass?' do expect(manifest_condition).to receive(:pass?) expect(subject.cached_pass?(ctx)).to eq pass_value end end context 'when not cached' do let(:cache_state) { false } it 'does not call pass' do expect(manifest_condition).not_to receive(:pass?) expect(subject.cached_pass?(ctx)).to be_nil end end end describe '#score' do # see ManifestCondition for scoring rules when cached. it 'delegates to the underlying condition' do expect(subject.score(ctx)).to eq 17 end end describe '#simplify' do it 'cannot be simplfied' do expect(subject.simplify).to be subject end end describe '#inspect' do it 'is represented by the condition name' do expect(subject.repr).to eq 'foo' end end end describe DeclarativePolicy::Rule::Not do let(:underlying) { DeclarativePolicy::Rule::Condition.new(:foo) } subject { described_class.new(underlying) } describe '#pass?' do it 'is the inverse of the underlying rule' do expect(subject.pass?(ctx)).to eq !underlying.pass?(ctx) end end describe '#cached_pass?' do context 'when cached' do it 'calls pass?' do expect(manifest_condition).to receive(:pass?) expect(subject.cached_pass?(ctx)).to eq !pass_value end end context 'when not cached' do let(:cache_state) { false } it 'does not call pass' do expect(manifest_condition).not_to receive(:pass?) expect(subject.cached_pass?(ctx)).to be_nil end end end describe '#score' do it 'delegates to the underlying condition' do expect(subject.score(ctx)).to eq 17 end end describe '#simplify' do it 'cannot be simplfied' do expect(subject.simplify.repr).to eq subject.repr end context 'when there is double-negation' do let(:underlying) { described_class.new(DeclarativePolicy::Rule::Condition.new(:foo)) } it 'discards the negation' do expect(subject.simplify.repr).to eq 'foo' end end context "with DeMorgan's Law" do let(:a) { DeclarativePolicy::Rule::Condition.new(:a) } let(:b) { DeclarativePolicy::Rule::Condition.new(:b) } context 'with !(a && b)' do let(:underlying) { a & b } it 'simplifies to (!a || !b)' do expect(subject.simplify.repr).to eq((~a | ~b).repr) end end context 'with !(a || b)' do let(:underlying) { a | b } it 'simplifies to (!a && !b)' do expect(subject.simplify.repr).to eq((~a & ~b).repr) end end end end describe '#inspect' do it 'is represented by the condition name prefixed with ~' do expect(subject.repr).to eq '~foo' end end end describe DeclarativePolicy::Rule::DelegatedCondition do subject { described_class.new(:wibble, :foo) } describe '#pass?' do context 'when the delegate does not exist' do it 'is false' do expect(subject.pass?(ctx)).to be false end end context 'when the delegate does exist' do let(:delegated_policies) { { wibble: ctx } } it 'is whatever the delegated condition returns' do expect(subject.pass?(ctx)).to eq pass_value end end end describe '#cached_pass?' do context 'when the delegate does not exist' do it 'is false' do expect(subject.cached_pass?(ctx)).to be false end end context 'when the delegate does exist' do let(:delegated_policies) { { wibble: ctx } } context 'when cached' do it 'calls pass?' do expect(manifest_condition).to receive(:pass?) expect(subject.cached_pass?(ctx)).to eq pass_value end end context 'when not cached' do let(:cache_state) { false } it 'does not call pass' do expect(manifest_condition).not_to receive(:pass?) expect(subject.cached_pass?(ctx)).to be_nil end end end end describe '#score' do context 'when the delegate does not exist' do it 'is zero' do expect(subject.score(ctx)).to eq 0 end end context 'when the delegate does exist' do let(:delegated_policies) { { wibble: ctx } } it 'delegates to the underlying condition' do expect(subject.score(ctx)).to eq 17 end end end describe '#simplify' do it 'cannot be simplfied' do expect(subject.simplify.repr).to eq subject.repr end end describe '#inspect' do it 'is represented by the delegate name followed by the condition name' do expect(subject.repr).to eq 'wibble.foo' end end end describe DeclarativePolicy::Rule::Ability do let(:runner) { instance_double(DeclarativePolicy::Runner, score: 13, cached?: cache_state, pass?: pass_value) } before do allow(ctx).to receive(:runner).with(:do_foo).and_return(runner) end subject { described_class.new(:do_foo) } describe '#pass?' do it 'is equivalent to calling Policy#allowed?(ability)' do expect(ctx).to receive(:allowed?).with(:do_foo).and_return(pass_value) expect(subject.pass?(ctx)).to eq pass_value end end describe '#cached_pass?' do context 'when cached' do it 'calls pass?' do expect(runner).to receive(:pass?) expect(subject.cached_pass?(ctx)).to eq pass_value end end context 'when not cached' do let(:cache_state) { false } it 'does not call pass' do expect(runner).not_to receive(:pass?) expect(subject.cached_pass?(ctx)).to be_nil end end end describe '#score' do it 'delegates to the runner' do expect(subject.score(ctx)).to eq 13 end end describe '#simplify' do it 'cannot be simplfied' do expect(subject.simplify.repr).to eq subject.repr end end describe '#inspect' do it 'is represented by can and the ability name' do expect(subject.repr).to eq 'can?(:do_foo)' end end end describe DeclarativePolicy::Rule::And do let(:rules) do [ DeclarativePolicy::Rule::Condition.new(:foo), DeclarativePolicy::Rule::Condition.new(:bar), DeclarativePolicy::Rule::Condition.new(:baz) ] end let(:bar) { { pass: true, cached: true, score: 7 } } let(:baz) { { pass: true, cached: true, score: 8 } } def cond(values) instance_double(DeclarativePolicy::ManifestCondition, pass?: values[:pass], cached?: values[:cached], score: values[:score]) end before do allow(ctx).to receive(:condition).with(:bar) { cond(bar) } allow(ctx).to receive(:condition).with(:baz) { cond(baz) } end subject { described_class.new(rules) } describe '#pass?' do it 'is equivalent to rules.all? { _1.pass? }' do expect(subject.pass?(ctx)).to be true end context 'when any rule does not pass' do it 'does not pass' do bar[:pass] = false expect(subject.pass?(ctx)).to be false end end end describe '#cached_pass?' do context 'when all rules are cached' do it 'calls pass? on each rule' do expect(subject.cached_pass?(ctx)).to be true end end context 'when not fully cached' do before do baz[:cached] = false end it 'does not call pass' do expect(subject.rules.last).not_to receive(:pass?) expect(subject.cached_pass?(ctx)).to be_nil end end context 'when not fully cached, but known to be false' do before do baz[:cached] = false bar[:pass] = false end it 'is false' do expect(subject.cached_pass?(ctx)).to be false end end end describe '#score' do context 'when fully cached' do it 'is zero' do expect(subject.score(ctx)).to eq 0 end end context 'when not fully cached' do before do baz[:cached] = false end it 'is the sum of the score of the rules' do expect(subject.score(ctx)).to eq(17 + 7 + 8) end end context 'when not fully cached, but known to be false' do before do baz[:cached] = false bar[:pass] = false end it 'is zero' do expect(subject.score(ctx)).to eq 0 end end end describe '#simplify' do it 'cannot be simplfied' do expect(subject.simplify.repr).to eq subject.repr end context 'when any of the rules are themselves AND nodes, or simplify to AND nodes' do let(:new_rules) do [ DeclarativePolicy::Rule::Condition.new(:x), DeclarativePolicy::Rule::Condition.new(:y), DeclarativePolicy::Rule::Condition.new(:w), DeclarativePolicy::Rule::Condition.new(:z) ] end before do x, y, w, z = new_rules and_node = described_class.new([x, y]) demorgan_and = DeclarativePolicy::Rule::Not.new(DeclarativePolicy::Rule::Or.new([w, z])) rules << and_node rules << demorgan_and end it 'flattens out any nested rules' do expect(subject.simplify.repr).to eq 'all?(foo, bar, baz, x, y, ~w, ~z)' end end end describe '#inspect' do it 'is represented by all? and the rules' do expect(subject.repr).to eq 'all?(foo, bar, baz)' end end end describe DeclarativePolicy::Rule::Or do let(:rules) do [ DeclarativePolicy::Rule::Condition.new(:foo), DeclarativePolicy::Rule::Condition.new(:bar), DeclarativePolicy::Rule::Condition.new(:baz) ] end let(:bar) { { pass: false, cached: true, score: 7 } } let(:baz) { { pass: false, cached: true, score: 8 } } def cond(values) instance_double(DeclarativePolicy::ManifestCondition, pass?: values[:pass], cached?: values[:cached], score: values[:score]) end before do allow(ctx).to receive(:condition).with(:bar) { cond(bar) } allow(ctx).to receive(:condition).with(:baz) { cond(baz) } end subject { described_class.new(rules) } describe '#pass?' do it 'is equivalent to rules.any? { _1.pass? }' do expect(subject.pass?(ctx)).to be true end context 'when no rule passes' do let(:pass_value) { false } it 'does not pass' do expect(subject.pass?(ctx)).to be false end end end describe '#cached_pass?' do context 'when all rules are cached' do it 'is true' do expect(subject.cached_pass?(ctx)).to be true end end context 'when any false rule is not cached' do before do baz[:cached] = false end it 'is true' do expect(subject.cached_pass?(ctx)).to be true end end context 'when the true rule is not cached' do let(:cache_state) { false } it 'is unknown' do expect(subject.cached_pass?(ctx)).to be_nil end end end describe '#score' do context 'when fully cached' do it 'is zero' do expect(subject.score(ctx)).to eq 0 end end context 'when the true condition is not cached' do let(:cache_state) { false } it 'is the sum of the score of the rules' do expect(subject.score(ctx)).to eq(17 + 7 + 8) end end context 'when a false condition is not cached' do before do bar[:cached] = false end it 'is zero' do expect(subject.score(ctx)).to eq 0 end end end describe '#simplify' do it 'cannot be simplfied' do expect(subject.simplify.repr).to eq subject.repr end context 'when any of the rules are themselves OR nodes, or simplify to OR nodes' do let(:new_rules) do [ DeclarativePolicy::Rule::Condition.new(:x), DeclarativePolicy::Rule::Condition.new(:y), DeclarativePolicy::Rule::Condition.new(:w), DeclarativePolicy::Rule::Condition.new(:z) ] end before do x, y, w, z = new_rules or_node = described_class.new([x, y]) demorgan_or = DeclarativePolicy::Rule::Not.new(DeclarativePolicy::Rule::And.new([w, z])) rules << or_node rules << demorgan_or end it 'flattens out any nested rules' do expect(subject.simplify.repr).to eq 'any?(foo, bar, baz, x, y, ~w, ~z)' end end end describe '#inspect' do it 'is represented by all? and the rules' do expect(subject.repr).to eq 'any?(foo, bar, baz)' end end end end runner_spec.rb000066400000000000000000000124371515507476000323500ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy# frozen_string_literal: true require 'rspec-parameterized' # NB: This spec relies heavily on the fact that calling unstubbed methods on doubles # will cause examples to fail. Only steps we anticipate being called have # `pass?` defined for them. All other steps are expected not to be called. RSpec.describe DeclarativePolicy::Runner do describe 'uncache!' do it 'allows the runner to run again' do step = make_step(score: 1, action: :enable, enable?: true) expect(step).to receive(:pass?).twice.and_return(true) runner = make_runner(step) runner.pass? runner.pass? runner.uncache! runner.pass? runner.pass? end end it 'short-circuits if there are no enabling steps' do prevent_1 = make_step(score: 1, enable?: false) prevent_2 = make_step(score: 1, enable?: false) prevent_3 = make_step(score: 1, enable?: false) runner = make_runner(prevent_1, prevent_2, prevent_3) expect(runner).not_to be_pass end it 'only runs the cheapest prevent step, if it succeeds' do prevent_1 = make_step(score: 0.5, enable?: false, action: :prevent, pass?: true) prevent_2 = make_step(score: 2, enable?: false) prevent_3 = make_step(score: 3, enable?: false) enable = make_step(score: 1, enable?: true) runner = make_runner(enable, prevent_1, prevent_2, prevent_3) expect(runner).not_to be_pass end it 'runs all prevent steps, if none succeed' do prevent_1 = make_step(score: 0.5, enable?: false, action: :prevent, pass?: false) prevent_2 = make_step(score: 2, enable?: false, action: :prevent, pass?: false) prevent_3 = make_step(score: 3, enable?: false, action: :prevent, pass?: false) enable = make_step(score: 1, enable?: true, action: :enable, pass?: true) runner = make_runner(enable, prevent_1, prevent_2, prevent_3) expect(runner).to be_pass end it 'runs all enabling steps if none succeed' do step_1 = make_step(score: 1, enable?: true, action: :enable, pass?: false) step_2 = make_step(score: 1, enable?: true, action: :enable, pass?: false) step_3 = make_step(score: 1, enable?: true, action: :enable, pass?: false) runner = make_runner(step_1, step_2, step_3) expect(runner).not_to be_pass end it 'skips expensive prevent steps if no cheaper enable succeeds' do enable_1 = make_step(score: 1, enable?: true, action: :enable, pass?: false) enable_2 = make_step(score: 1, enable?: true, action: :enable, pass?: false) enable_3 = make_step(score: 1, enable?: true, action: :enable, pass?: false) prevent = make_step(score: 2, enable?: false) runner = make_runner(prevent, enable_1, enable_2, enable_3) expect(runner).not_to be_pass end it 'skips more expensive enabling steps if one succeeds' do enable_1 = make_step(score: 3, enable?: true) enable_2 = make_step(score: 2, enable?: true, action: :enable, pass?: true) enable_3 = make_step(score: 1, enable?: true, action: :enable, pass?: false) runner = make_runner(enable_1, enable_2, enable_3) expect(runner).to be_pass end it 'picks up steps that become cheaper during execution' do variable_cost = 3 step_1 = make_step(enable?: true, action: :enable) step_2 = make_step(enable?: true, action: :enable, score: 2) step_3 = make_step(enable?: true, action: :enable, score: 1) allow(step_1).to receive(:score) { variable_cost } expect(step_3).to receive(:pass?) do variable_cost = 0 false end expect(step_1).to receive(:pass?).and_return(true) runner = make_runner(step_1, step_2, step_3) expect(runner).to be_pass end describe '#dependencies' do let(:policy) do Class.new(DeclarativePolicy::Base) do condition(:hot, score: 1) { @subject > 30 } condition(:cold, score: 2) { @subject < 15 } condition(:works_in_office, score: 3) { @user&.works_in_office? } rule { ~hot & ~cold }.enable :enjoy_weather rule { ~cold }.enable :wear_shorts rule { works_in_office }.prevent :wear_shorts # tests nested runners rule { can?(:wear_shorts) }.enable :wear_tshirt # Tests doubly-nested runners rule { can?(:enjoy_weather) & can?(:wear_tshirt) }.enable :bonza end end it 'tracks dependencies, cached or fresh' do p = policy.new(nil, 20) expect(p).to be_allowed(:enjoy_weather) expect(p).to be_allowed(:wear_shorts) expect(p).to be_allowed(:wear_tshirt) expect(p).to be_allowed(:bonza) expect(p.runner(:enjoy_weather).dependencies).to contain_exactly(/hot/, /cold/) expect(p.runner(:wear_shorts).dependencies).to contain_exactly(/cold/, /works_in_office/) expect(p.runner(:wear_tshirt).dependencies).to contain_exactly(/cold/, /works_in_office/) expect(p.runner(:bonza).dependencies).to contain_exactly(/hot/, /cold/, /works_in_office/) end it 'only tracks evaluated dependencies' do p = policy.new(nil, 31) expect(p).not_to be_allowed(:enjoy_weather) expect(p.runner(:enjoy_weather).dependencies).to contain_exactly(/hot/) end end def make_step(**args) instance_double(DeclarativePolicy::Step, **args) end def make_runner(*steps) steps.each do |step| allow(step).to receive(:flattened).and_return([step]) end described_class.new(steps) end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/declarative_policy_spec.rb000066400000000000000000000146131515507476000311140ustar00rootroot00000000000000# frozen_string_literal: true RSpec.describe DeclarativePolicy do describe '.class_for' do context 'when the value is nil' do it 'uses the default fallback policy' do expect(described_class.class_for(nil)).to eq(DeclarativePolicy::NilPolicy) end context 'when nil_policy has been configured' do let(:custom_nil_policy) { Class.new(DeclarativePolicy::Base) } before do policy = custom_nil_policy described_class.configure do nil_policy policy end end it 'uses the custom class' do expect(described_class.class_for(nil)).to eq(custom_nil_policy) end end end context 'when the value is a symbol' do it 'uses the configured class' do expect(described_class.class_for(:global)).to eq(GlobalPolicy) end it 'raises an error if no policy was configured' do expect { described_class.class_for(:custom) }.to raise_error('No custom policy configured') end context 'when a policy has been configured' do let(:custom_policy) { Class.new(DeclarativePolicy::Base) } let(:my_global_policy) { Class.new(DeclarativePolicy::Base) } before do custom = custom_policy global = my_global_policy described_class.configure do named_policy :custom, custom named_policy :global, global end end it 'returns the configured policy' do expect(described_class.class_for(:global)).to eq(my_global_policy) expect(described_class.class_for(:custom)).to eq(custom_policy) end end end context 'when the policy class is present' do before do stub_const('Foo', Class.new) stub_const('FooPolicy', Class.new(DeclarativePolicy::Base)) end it 'uses declarative_policy_class' do instance = Foo.new expect(described_class.class_for(instance)).to eq(FooPolicy) end end context 'when there is no policy for the class, but there is one for a superclass' do before do foo = Class.new stub_const('Foo', foo) stub_const('Bar', Class.new(foo)) stub_const('FooPolicy', Class.new(DeclarativePolicy::Base)) end it 'uses declarative_policy_class' do instance = Bar.new expect(described_class.class_for(instance)).to eq(FooPolicy) end end context 'when name transformation has been configured' do before do stub_const('Bar', Class.new) stub_const('Policies::Bar', Class.new(DeclarativePolicy::Base)) described_class.configure do name_transformation { |name| "Policies::#{name}" } end end it 'uses the configured transformation' do expect(described_class.class_for(Bar.new)).to eq(Policies::Bar) end end it 'raises error if not found' do instance = Object.new expect { described_class.class_for(instance) }.to raise_error('no policy for Object') end context 'when found policy class does not inherit base' do before do stub_const('Foo', Class.new) stub_const('FooPolicy', Class.new) end it 'raises error if inferred class does not inherit Base' do instance = Foo.new expect { described_class.class_for(instance) }.to raise_error('no policy for Foo') end end end describe '.policy_for' do before do stub_const('Human', Class.new { attr_accessor :id }) stub_const('Bot', Class.new { attr_accessor :id }) stub_const('Foo', Class.new) stub_const('FooPolicy', Class.new(DeclarativePolicy::Base)) end context 'when the policy has been instantiated before' do it 'returns the same instance' do cache = {} user = Human.new user.id = 100 object = Foo.new expect(described_class.policy_for(user, object, cache: cache)) .to equal(described_class.policy_for(user, object, cache: cache)) end end context 'when actors have different types, but the same ID' do it 'returns different instances' do cache = {} user = Human.new user.id = 100 bot = Bot.new bot.id = 100 object = Foo.new expect(described_class.policy_for(user, object, cache: cache)) .not_to be(described_class.policy_for(bot, object, cache: cache)) end end end describe '.invalidate' do let(:user) { User.new(name: 'Filbert', driving_license: License[:valid], trusted: ['Finnigan']) } let(:other_user) { User.new(name: 'Finnigan') } let(:car) do country = Country.moderate reg = Registration.new(country: country) Vehicle.new(owner: user, registration: reg) end let(:cache) { {} } let(:keys) do [ '/dp/condition/ReadmePolicy/has_driving_license/User:Filbert', '/dp/condition/ReadmePolicy/has_driving_license/User:Finnigan' ] end def filbert_can_drive ReadmePolicy.new(user, car, cache: cache).allowed?(:drive_vehicle) end def finn_can_drive ReadmePolicy.new(other_user, car, cache: cache).allowed?(:drive_vehicle) end def swap_licenses! other_user.driving_license = user.driving_license # invalidates policy user.driving_license = nil # invalidates policy end it 'is possible to invalidate a runner, and clear dirty state' do # verifies that we benefit from caching for other conditions expect(user).to receive(:trusts?).with(other_user).once.and_call_original expect(filbert_can_drive).to be true expect(finn_can_drive).to be false swap_licenses! # state is still stale expect(filbert_can_drive).to be true expect(finn_can_drive).to be false expect { described_class.invalidate(cache, keys.take(1)) } .to change { cache.size }.by(-1) expect(filbert_can_drive).to be false # state is now good! expect(finn_can_drive).to be false # but this is still stale expect { described_class.invalidate(cache, keys.drop(1)) } .to change { finn_can_drive }.from(false).to(true) end it 'can invalidate several keys at once' do expect do swap_licenses! described_class.invalidate(cache, keys) end .to change { filbert_can_drive }.from(true).to(false) .and change { finn_can_drive }.from(false).to(true) end end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/feature/000077500000000000000000000000001515507476000253415ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/feature/caching_spec.rb000066400000000000000000000074521515507476000303040ustar00rootroot00000000000000# frozen_string_literal: true # Tests caching promises, as well as the code presented in doc/caching.md # rubocop:disable RSpec/VerifiedDoubles RSpec.describe 'caching' do let(:policy) { CountryPolicy.new(user, country, cache: cache) } let(:cache) { {} } let(:user) { User.new(name: 'Hans', citizenships: [double(code: :de, number: 1234)]) } let(:european_union) { %i[de fr it] } let(:visas) { double(:Visas) } let(:current_visa) { nil } before do stub_const('Unions::EU', european_union) allow(visas).to receive(:find_by).once.with(applicant: user).and_return(current_visa) allow(country.visa_waivers).to receive(:any?).once.and_call_original end context 'when the country is a member of the EU' do let(:country) { Country.new(name: 'France', active_visas: visas) } it 'is OK to visit, work and settle in another EU country' do expect(policy).to be_allowed(:enter_country, :work, :settle) end it 'does not confer voting rights' do expect(policy).not_to be_allowed(:vote) end it 'tests citizenship at most once' do # This tests that we prefer to test EU citizenship before specific # citizenship, because EU citizenship is split into a user and a subject # scope, and is thus has a lower score than `full_rights` expect(user).to receive(:citizen_of?).once.and_call_original policy.allowed?(:settle) end context 'when many users are visiting a country' do let(:users) do %w[Hans Frieda Hilda Mattias].each_with_index.map do |name, i| citizenship = double(code: :de, number: i) User.new(name: name, citizenships: [citizenship]) end end it 'only checks EU membership once' do allow(cache).to receive(:[]=).with(String, anything).and_call_original expect(cache).to receive(:[]=).once.with(/eu_member/, anything).and_call_original expect(cache).to receive(:[]=).with(/eu_citizen/, anything).exactly(4).times.and_call_original ok = users.all? do |user| CountryPolicy.new(user, country, cache: cache).allowed?(:enter_country) end expect(ok).to be true end end context 'when one user is visiting many countries' do let(:countries) do %w[France Italia].map { |name| Country.new(name: name, active_visas: visas) } end it 'only checks EU citizenship once' do allow(cache).to receive(:[]=).with(String, anything).and_call_original expect(cache).to receive(:[]=).once.with(/eu_citizen/, anything).and_call_original expect(cache).to receive(:[]=).twice.with(/eu_member/, anything).and_call_original ok = countries.all? do |country| CountryPolicy.new(user, country, cache: cache).allowed?(:enter_country) end expect(ok).to be true end end end context 'when the user comes from a country with a visa waiver arrangment' do let(:country) { Country.new(name: 'NZ', active_visas: visas) } before do country.visa_waivers << :de end it 'is OK to visit and attend meetings, and apply for a real visa' do expect(policy).to be_allowed(:enter_country, :attend_meetings, :apply_for_visa) end it 'is not OK to work or settle' do expect(policy).not_to be_allowed(:work, :settle) end context 'when the user has a work visa' do let(:current_visa) { double(category: :work) } it 'is OK to work, but not settle' do expect(policy).to be_allowed(:work) expect(policy).not_to be_allowed(:settle) end end context 'when the user is banned' do before do country.banned_list << user end it 'is not OK to enter the country' do expect(policy).not_to be_allowed(:enter_country) end end end end # rubocop:enable RSpec/VerifiedDoubles conditions_and_rules_spec.rb000066400000000000000000000142071515507476000330320ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/feature# frozen_string_literal: true RSpec.describe 'conditions and rules' do include_context 'with vehicle policy' let(:user) { User.new(name: name, age: age, driving_license: License[license], blood_alcohol: blood_alcohol) } let(:car) do owner = User.new(name: :owner, trusted: trusted_users) country = Country.moderate allow(country).to receive(:current_driving_conviction?).with(user).and_return(banned) reg = Registration.new(country: country) Vehicle.new(owner: owner, registration: reg) end let(:cache) { {} } def policy vehicle_policy.new(user, car, cache: cache) end shared_context 'with vehicle policy scenarios with permissions' do where(:name, :age, :trusted_users, :license, :banned, :blood_alcohol, :can, :cannot) do [ # full permissions [:owner, 18, [], :valid, false, 0.0, [:drive_vehicle, :sell_vehicle], []], # drive only [:driver, 18, [:driver], :valid, false, 0.0, [:drive_vehicle], [:sell_vehicle]], [:driver, 18, [:driver], :valid, false, 0.001, [:drive_vehicle], [:sell_vehicle]], # sell-only [:owner, 17, [:driver], :valid, false, 0.0, [:sell_vehicle], [:drive_vehicle]], [:owner, 18, [:driver], :valid, false, 0.2, [:sell_vehicle], [:drive_vehicle]], [:owner, 18, [:driver], :expired, false, 0.0, [:sell_vehicle], [:drive_vehicle]], [:owner, 18, [:driver], nil, false, 0.0, [:sell_vehicle], [:drive_vehicle]], [:owner, 18, [:driver], nil, true, 0.0, [:sell_vehicle], [:drive_vehicle]], [:owner, 18, [:driver], nil, true, 0.001, [:sell_vehicle], [:drive_vehicle]] ] end end shared_context 'with vehicle policy scenarios without permissions' do where(:name, :age, :trusted_users, :license, :banned, :blood_alcohol, :cannot) do [ # no permissions [:driver, 17, [:driver], :valid, false, 0.0, [:drive_vehicle, :sell_vehicle]], [:driver, 18, [], :valid, false, 0.0, [:drive_vehicle, :sell_vehicle]], [:driver, 18, [:driver], :valid, false, 0.2, [:drive_vehicle, :sell_vehicle]], [:driver, 18, [:driver], :expired, false, 0.2, [:drive_vehicle, :sell_vehicle]], [:driver, 18, [:driver], nil, false, 0.0, [:drive_vehicle, :sell_vehicle]], [:driver, 18, [:driver], :valid, true, 0.0, [:drive_vehicle, :sell_vehicle]] ] end end describe 'the Vehicle policy' do context 'when no arguments are passed to allowed?' do let(:name) { :driver } let(:age) { 18 } let(:trusted_users) { [] } let(:license) { :valid } let(:banned) { false } let(:blood_alcohol) { 0.0 } it 'returns false' do expect(policy.allowed?).to be false end it 'is disallowed by default' do expect(policy).to be_disallowed end end context 'with permissions' do include_context 'with vehicle policy scenarios with permissions' with_them do specify do expect(policy).to be_allowed(*can) end specify do expect(policy).to be_disallowed(*cannot) end context 'with a nested policy definition' do let(:policy) { nested_vehicle_policy.new(user, car, cache: cache) } it 'is functionally identical to the declarative API' do expect(policy).to be_allowed(*can) expect(policy).to be_disallowed(*cannot) end end context 'with a forgetful cache' do before do allow(cache).to receive(:[]).and_return(nil) end it 'has no effect on the correctness of the results' do expect(policy).to be_allowed(*can) expect(policy).to be_disallowed(*cannot) end end end end context 'without permissions' do include_context 'with vehicle policy scenarios without permissions' with_them do specify do expect(policy).to be_disallowed(*cannot) end context 'with a nested policy definition' do let(:policy) { nested_vehicle_policy.new(user, car, cache: cache) } it 'is functionally identical to the declarative API' do expect(policy).to be_disallowed(*cannot) end end context 'with a forgetful cache' do before do allow(cache).to receive(:[]).and_return(nil) end it 'has no effect on the correctness of the results' do expect(policy).to be_disallowed(*cannot) end end end end end describe 'ability inference' do let(:policy_definition) { Class.new(vehicle_policy) } let(:policy) { policy_definition.new(user, car, cache: {}) } before do policy_definition.rule { default }.enable(:take_bus) policy_definition.rule { can?(:sell_vehicle) }.enable(:trade_in_vehicle) policy_definition.rule { can?(:drive_vehicle) }.prevent(:take_bus) end describe 'allowed?' do context 'with permissions' do include_context 'with vehicle policy scenarios with permissions' with_them do specify do if policy.allowed?(:sell_vehicle) expect(policy).to be_allowed(:trade_in_vehicle) else expect(policy).to be_disallowed(:trade_in_vehicle) end end specify do if policy.allowed?(:drive_vehicle) expect(policy).to be_disallowed(:take_bus) else expect(policy).to be_allowed(:take_bus) end end end end context 'without permissions' do include_context 'with vehicle policy scenarios without permissions' with_them do specify do if policy.allowed?(:sell_vehicle) expect(policy).to be_allowed(:trade_in_vehicle) else expect(policy).to be_disallowed(:trade_in_vehicle) end end specify do if policy.allowed?(:drive_vehicle) expect(policy).to be_disallowed(:take_bus) else expect(policy).to be_allowed(:take_bus) end end end end end end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/feature/debug_spec.rb000066400000000000000000000044001515507476000277640ustar00rootroot00000000000000# frozen_string_literal: true RSpec.describe 'debugging' do include_context 'with vehicle policy' def policy user = User.new(name: :driver, driving_license: License[:valid], blood_alcohol: 0.005) reg = Registration.new(country: country) car = Vehicle.new(owner: owner, registration: reg) vehicle_policy.new(user, car, cache: {}) end describe '#debug' do let(:owner) { User.new(name: :owner, trusted: [:driver]) } context 'when the policy succeeds' do let(:country) { Country.moderate } it 'shows the executed conditions' do out = [] policy.debug(:drive_vehicle, out) expect(out).to match [ start_with("- [0] enable when owns"), start_with("+ [3] enable when has_access_to"), start_with("- [3] prevent when banned"), start_with("- [4] prevent when intoxicated"), start_with("- [7] prevent when ~has_driving_license"), start_with("- [14] prevent when ~old_enough_to_drive") ] end end context 'when the policy is never enabled' do let(:country) { Country.moderate } let(:owner) { User.new(name: :owner, trusted: []) } it 'shows the executed conditions' do out = [] policy.debug(:drive_vehicle, out) expect(out).to match [ start_with("- [0] enable when owns"), start_with("- [3] enable when has_access_to"), start_with(" [3] prevent when banned"), start_with(" [4] prevent when intoxicated"), start_with(" [7] prevent when ~has_driving_license"), start_with(" [14] prevent when ~old_enough_to_drive") ] end end context 'when the policy fails due to prevention' do let(:country) { Country.strict } it 'shows the executed conditions' do out = [] policy.debug(:drive_vehicle, out) expect(out).to match [ start_with("- [0] enable when owns"), start_with("+ [3] enable when has_access_to"), start_with("- [3] prevent when banned"), start_with("+ [4] prevent when intoxicated"), start_with(" [7] prevent when ~has_driving_license"), start_with(" [14] prevent when ~old_enough_to_drive") ] end end end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/spec_helper.rb000066400000000000000000000012431515507476000265240ustar00rootroot00000000000000# frozen_string_literal: true require 'bundler/setup' require 'pry-byebug' if RUBY_ENGINE != 'jruby' require 'rspec-parameterized' require 'declarative_policy' Dir["./spec/support/**/*.rb"].each { |f| require f } RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! config.expect_with :rspec do |c| c.syntax = :expect end config.around do |example| DeclarativePolicy.configure! do named_policy :global, GlobalPolicy end example.run end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/000077500000000000000000000000001515507476000254225ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models/000077500000000000000000000000001515507476000267055ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models/country.rb000066400000000000000000000016241515507476000307400ustar00rootroot00000000000000# frozen_string_literal: true class Country attr_reader :name, :driving_laws, :visa_waivers, :active_visas, :country_code, :banned_list def initialize( name:, driving_laws: nil, code: nil, visa_waivers: [], active_visas: [], banned_list: [], convictions: {}) @name = name @driving_laws = driving_laws || Laws.new(max_blood_alcohol: 0.01, minimum_age: 18) @visa_waivers = visa_waivers @active_visas = active_visas @country_code = code || name.downcase[0..1].to_sym @banned_list = banned_list @convictions = convictions end def id country_code end def self.strict new(name: 'Strictopia', driving_laws: Laws.new(max_blood_alcohol: 0.001, minimum_age: 21)) end def self.moderate new(name: 'Moderia', driving_laws: Laws.new(max_blood_alcohol: 0.01, minimum_age: 18)) end def current_driving_conviction?(user) @convictions.key?(user) end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models/laws.rb000066400000000000000000000003411515507476000301760ustar00rootroot00000000000000# frozen_string_literal: true class Laws attr_reader :max_blood_alcohol, :minimum_age def initialize(max_blood_alcohol:, minimum_age:) @max_blood_alcohol = max_blood_alcohol @minimum_age = minimum_age end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models/license.rb000066400000000000000000000005141515507476000306540ustar00rootroot00000000000000# frozen_string_literal: true class License def initialize(expiry:) @expiry = expiry end def valid? Time.now <= @expiry end def self.[](type) case type when :valid then new(expiry: (Time.now + (60 * 60 * 24 * 365 * 10))) when :expired then new(expiry: (Time.now - (60 * 60 * 24))) end end end registration.rb000066400000000000000000000002611515507476000316640ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models# frozen_string_literal: true class Registration attr_reader :number, :country def initialize(country:, number: nil) @number = number @country = country end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models/user.rb000066400000000000000000000013351515507476000302120ustar00rootroot00000000000000# frozen_string_literal: true class User attr_reader :name, :age, :blood_alcohol attr_accessor :driving_license def initialize(name:, age: 30, driving_license: nil, blood_alcohol: 0.0, trusted: [], citizenships: []) @name = name @age = age @driving_license = driving_license @blood_alcohol = blood_alcohol @trusted = trusted @citizenships = citizenships end def trusts?(user) user && @trusted.include?(user.name) end def id return @name if @citizenships.empty? @citizenships.map { |c| "#{c.code}:#{c.number}" }.join(";") end def citizen_of?(*country_codes) country_codes.any? { |c| @citizenships.map(&:code).include?(c) } end alias_method :to_reference, :name end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/models/vehicle.rb000066400000000000000000000002701515507476000306500ustar00rootroot00000000000000# frozen_string_literal: true class Vehicle attr_reader :owner, :registration def initialize(owner:, registration:) @owner = owner @registration = registration end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/policies/000077500000000000000000000000001515507476000272315ustar00rootroot00000000000000country_policy.rb000066400000000000000000000030161515507476000325610ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/policies# frozen_string_literal: true 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 global_policy.rb000066400000000000000000000002411515507476000323130ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/policies# frozen_string_literal: true class GlobalPolicy < DeclarativePolicy::Base rule { anonymous }.prevent :drive_car rule { ~anonymous }.enable :say_hello end readme_policy.rb000066400000000000000000000020101515507476000323040ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/policies# frozen_string_literal: true # See README.md class ReadmePolicy < DeclarativePolicy::Base condition(:old_enough_to_drive) { @user.age >= laws.minimum_age } condition(:has_driving_license, scope: :user) { @user.driving_license&.valid? } condition(:owns, score: 0) { @subject.owner.name == @user.name } condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) } condition(:intoxicated, score: 5) { @user.blood_alcohol > laws.max_blood_alcohol } condition(:banned, score: 4) { @subject.registration.country.current_driving_conviction?(@user) } # 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 { banned }.prevent :drive_vehicle rule { ~has_driving_license }.prevent :drive_vehicle rule { owns }.enable :sell_vehicle # we can use methods to abstract common logic def laws @subject.registration.country.driving_laws end end declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/shared_contexts/000077500000000000000000000000001515507476000306175ustar00rootroot00000000000000vehicle_model_context.rb000066400000000000000000000022161515507476000354310ustar00rootroot00000000000000declarative-policy-2.1.0-7cd092d88be34930f8310d6f33c965ccbdbe2268/spec/support/shared_contexts# frozen_string_literal: true RSpec.shared_context 'with vehicle policy' do let(:vehicle_policy) { ReadmePolicy } # functionally identical to the ReadmePolicy, but the conditions are combined differently let(:nested_vehicle_policy) do Class.new(DeclarativePolicy::Base) do condition(:old_enough_to_drive) { @user.age >= laws.minimum_age } condition(:has_driving_license) { @user.driving_license&.valid? } condition(:owns, score: 0) { @subject.owner.name == @user.name } condition(:has_access_to, score: 3) { @subject.owner.trusts?(@user) } condition(:intoxicated, score: 5) { @user.blood_alcohol > laws.max_blood_alcohol } condition(:banned, score: 4) { @subject.registration.country.current_driving_conviction?(@user) } # The rule as one big nested condition rule do (owns | has_access_to) & old_enough_to_drive & ~(intoxicated | banned) & has_driving_license end.enable :drive_vehicle rule { owns }.enable :sell_vehicle def laws @subject.registration.country.driving_laws end end end before do stub_const('VehiclePolicy', vehicle_policy) end end