├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── gem_release_template.md ├── pull_request_template.md └── workflows │ ├── main.yml │ └── push_gem.yml ├── .gitignore ├── .rubocop.yml ├── .rubocop_ignore_git.yml ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── SECURITY.md ├── config └── rubocop-rspec.yml ├── lib ├── generators │ ├── pundit │ │ ├── install │ │ │ ├── USAGE │ │ │ ├── install_generator.rb │ │ │ └── templates │ │ │ │ └── application_policy.rb.tt │ │ └── policy │ │ │ ├── USAGE │ │ │ ├── policy_generator.rb │ │ │ └── templates │ │ │ └── policy.rb.tt │ ├── rspec │ │ ├── policy_generator.rb │ │ └── templates │ │ │ └── policy_spec.rb.tt │ └── test_unit │ │ ├── policy_generator.rb │ │ └── templates │ │ └── policy_test.rb.tt ├── pundit.rb └── pundit │ ├── authorization.rb │ ├── cache_store.rb │ ├── cache_store │ ├── legacy_store.rb │ └── null_store.rb │ ├── context.rb │ ├── error.rb │ ├── helper.rb │ ├── policy_finder.rb │ ├── railtie.rb │ ├── rspec.rb │ └── version.rb ├── pundit.gemspec └── spec ├── authorization_spec.rb ├── generators_spec.rb ├── policies └── post_policy_spec.rb ├── policy_finder_spec.rb ├── pundit └── helper_spec.rb ├── pundit_spec.rb ├── rspec_dsl_spec.rb ├── simple_cov_check_action_formatter.rb ├── spec_helper.rb └── support ├── lib ├── controller.rb ├── custom_cache.rb └── instance_tracking.rb ├── models ├── article.rb ├── article_tag.rb ├── artificial_blog.rb ├── blog.rb ├── comment.rb ├── comment_four_five_six.rb ├── comment_scope.rb ├── comments_relation.rb ├── customer │ └── post.rb ├── default_scope_contains_error.rb ├── dummy_current_user.rb ├── foo.rb ├── post.rb ├── post_four_five_six.rb ├── project_one_two_three │ ├── avatar_four_five_six.rb │ └── tag_four_five_six.rb └── wiki.rb └── policies ├── article_tag_other_name_policy.rb ├── base_policy.rb ├── blog_policy.rb ├── comment_policy.rb ├── criteria_policy.rb ├── default_scope_contains_error_policy.rb ├── denier_policy.rb ├── dummy_current_user_policy.rb ├── nil_class_policy.rb ├── post_policy.rb ├── project ├── admin │ └── comment_policy.rb ├── comment_policy.rb ├── criteria_policy.rb └── post_policy.rb ├── project_one_two_three ├── avatar_four_five_six_policy.rb ├── comment_four_five_six_policy.rb ├── criteria_four_five_six_policy.rb ├── post_four_five_six_policy.rb └── tag_four_five_six_policy.rb ├── publication_policy.rb └── wiki_policy.rb /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to report a problem 4 | title: '' 5 | labels: problem 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps or runnable code to reproduce the problem. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea 4 | title: '' 5 | labels: ['feature request'] 6 | assignees: '' 7 | --- 8 | 9 | **Please consider** 10 | - Could this feature break backwards-compatibility? 11 | - Could this feature benefit the many who use Pundit? 12 | - Could this feature be useful in _most_ projects that use Pundit? 13 | - Would this feature require Rails? 14 | - Am I open to creating a Pull Request with the necessary changes? 15 | 16 | **Is your feature request related to a problem? Please describe.** 17 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 18 | 19 | **Describe the solution you'd like** 20 | A clear and concise description of how you'd like to approach solving the problem. 21 | 22 | **Describe alternatives you've considered** 23 | A clear and concise description of any alternative solutions or features you've considered. 24 | 25 | **Additional context** 26 | Add any other context. Ex. if you've solved this problem in your own projects already, how that worked, and why the feature should be moved and maintained in Pundit instead. 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/gem_release_template.md: -------------------------------------------------------------------------------- 1 | ## To do 2 | 3 | - [ ] Make changes: 4 | - [ ] Bump `Pundit::VERSION` in `lib/pundit/version.rb`. 5 | - [ ] Update `CHANGELOG.md`. 6 | - [ ] Open pull request 🚀 and merge it. 7 | - [ ] Run [push gem](https://github.com/varvet/pundit/actions/workflows/push_gem.yml) GitHub Action. 8 | - [ ] Make an announcement in [Pundit discussions](https://github.com/varvet/pundit/discussions/categories/announcements) -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## To do 2 | 3 | - [ ] I have read the [contributing guidelines](https://github.com/varvet/pundit/contribute). 4 | - [ ] I have added relevant tests. 5 | - [ ] I have adjusted relevant documentation. 6 | - [ ] I have made sure the individual commits are meaningful. 7 | - [ ] I have added relevant lines to the CHANGELOG. 8 | 9 | PS: Thank you for contributing to Pundit ❤️ 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | CC_TEST_REPORTER_ID: "ac477089fe20ab4fc7e0d304cab75f72d73d58a7596d366935d18fcc7d51f8f9" 14 | 15 | # `github.ref` points to the *merge commit* when running tests on a pull request, which will be a commit 16 | # that doesn't exists in our code base. Since this workflow triggers from a PR, we use the HEAD SHA instead. 17 | # 18 | # NOTE: These are both used by Code Climate (cc-test-reporter). 19 | GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }} 20 | GIT_BRANCH: ${{ github.head_ref }} 21 | 22 | jobs: 23 | matrix-test: 24 | runs-on: ubuntu-latest 25 | continue-on-error: ${{ matrix.allow-failure || false }} 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | ruby-version: 30 | - "3.1" 31 | - "3.2" 32 | - "3.3" 33 | - "jruby-9.3.15" 34 | - "jruby" 35 | include: # HEAD-versions 36 | - ruby-version: "head" 37 | allow-failure: true 38 | - ruby-version: "jruby-head" 39 | allow-failure: true 40 | - ruby-version: "truffleruby-head" 41 | allow-failure: true 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Set up Ruby 46 | uses: ruby/setup-ruby@v1 47 | with: 48 | rubygems: latest 49 | ruby-version: ${{ matrix.ruby-version }} 50 | bundler-cache: true 51 | - name: Run tests 52 | run: bundle exec rspec 53 | 54 | test: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Set up Ruby 59 | uses: ruby/setup-ruby@v1 60 | with: 61 | rubygems: latest 62 | ruby-version: "ruby" 63 | bundler-cache: true 64 | - name: "Download cc-test-reporter from codeclimate.com" 65 | run: | 66 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 67 | chmod +x ./cc-test-reporter 68 | - name: "Report to Code Climate that we will send a coverage report." 69 | run: ./cc-test-reporter before-build 70 | - name: Run tests 71 | run: bundle exec rspec 72 | env: 73 | COVERAGE: 1 74 | - name: Upload coverage results 75 | uses: actions/upload-artifact@v4 76 | with: 77 | include-hidden-files: true 78 | name: coverage-results 79 | path: coverage 80 | retention-days: 1 81 | - name: Upload code coverage to Code Climate 82 | run: | 83 | ./cc-test-reporter after-build \ 84 | --coverage-input-type simplecov \ 85 | ./coverage/.resultset.json 86 | 87 | coverage-check: 88 | permissions: 89 | contents: read 90 | checks: write 91 | needs: test 92 | runs-on: ubuntu-latest 93 | steps: 94 | - uses: actions/checkout@v4 95 | - name: Download coverage results 96 | uses: actions/download-artifact@v4 97 | with: 98 | name: coverage-results 99 | path: coverage 100 | - uses: joshmfrankel/simplecov-check-action@be89e11889202cc59efb14aab2a7091622fa9aad 101 | with: 102 | github_token: ${{ secrets.GITHUB_TOKEN }} 103 | minimum_suite_coverage: 100 104 | minimum_file_coverage: 100 105 | coverage_json_path: coverage/simplecov-check-action.json 106 | 107 | rubocop: 108 | runs-on: ubuntu-latest 109 | steps: 110 | - uses: actions/checkout@v4 111 | - name: Set up Ruby 112 | uses: ruby/setup-ruby@v1 113 | with: 114 | rubygems: default 115 | ruby-version: "ruby" 116 | bundler-cache: false 117 | - run: bundle install 118 | - name: Run RuboCop 119 | run: bundle exec rubocop 120 | 121 | docs: 122 | runs-on: ubuntu-latest 123 | steps: 124 | - uses: actions/checkout@v4 125 | - name: Set up Ruby 126 | uses: ruby/setup-ruby@v1 127 | with: 128 | rubygems: default 129 | ruby-version: "ruby" 130 | bundler-cache: false 131 | - run: bundle install 132 | - run: rake yard 133 | 134 | required-checks: 135 | runs-on: ubuntu-latest 136 | if: ${{ always() }} 137 | needs: 138 | - test 139 | - matrix-test 140 | - docs 141 | - rubocop 142 | steps: 143 | - name: failure 144 | if: ${{ failure() || contains(needs.*.result, 'failure') }} 145 | run: exit 1 146 | - name: success 147 | run: exit 0 148 | -------------------------------------------------------------------------------- /.github/workflows/push_gem.yml: -------------------------------------------------------------------------------- 1 | name: Push Gem 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | push: 11 | if: github.repository == 'varvet/pundit' 12 | runs-on: ubuntu-latest 13 | 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | steps: 19 | # Set up 20 | - name: Harden Runner 21 | uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 22 | with: 23 | egress-policy: audit 24 | 25 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 26 | - name: Set up Ruby 27 | uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0 28 | with: 29 | bundler-cache: true 30 | ruby-version: ruby 31 | 32 | # Release 33 | - uses: rubygems/release-gem@612653d273a73bdae1df8453e090060bb4db5f31 # v1+ unreleased 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .coverage 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | bin 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_ignore_git.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 3.1 5 | SuggestExtensions: false 6 | NewCops: disable 7 | 8 | Gemspec/DeprecatedAttributeAssignment: 9 | Enabled: true 10 | 11 | Gemspec/DevelopmentDependencies: 12 | Enabled: true 13 | 14 | Metrics/BlockLength: 15 | Exclude: 16 | - "**/*_spec.rb" 17 | - pundit.gemspec 18 | 19 | Metrics/MethodLength: 20 | Max: 40 21 | 22 | Metrics/ModuleLength: 23 | Max: 200 24 | Exclude: 25 | - "**/*_spec.rb" 26 | 27 | Layout/LineLength: 28 | Max: 120 29 | 30 | Gemspec/RequiredRubyVersion: 31 | Enabled: false 32 | 33 | Layout/ParameterAlignment: 34 | EnforcedStyle: with_fixed_indentation 35 | 36 | Layout/CaseIndentation: 37 | EnforcedStyle: case 38 | SupportedStyles: 39 | - case 40 | - end 41 | IndentOneStep: true 42 | 43 | Layout/FirstArrayElementIndentation: 44 | EnforcedStyle: consistent 45 | 46 | Layout/FirstHashElementIndentation: 47 | EnforcedStyle: consistent 48 | 49 | Layout/EndAlignment: 50 | EnforcedStyleAlignWith: variable 51 | 52 | Style/PercentLiteralDelimiters: 53 | PreferredDelimiters: 54 | "%w": "[]" 55 | "%W": "[]" 56 | 57 | Style/StringLiterals: 58 | EnforcedStyle: double_quotes 59 | 60 | Style/StringLiteralsInInterpolation: 61 | EnforcedStyle: double_quotes 62 | 63 | Style/StructInheritance: 64 | Enabled: false 65 | 66 | Style/DoubleNegation: 67 | Enabled: false 68 | 69 | Style/Documentation: 70 | Enabled: false # TODO: Enable again once we have more docs 71 | 72 | Style/HashSyntax: 73 | EnforcedShorthandSyntax: never 74 | -------------------------------------------------------------------------------- /.rubocop_ignore_git.yml: -------------------------------------------------------------------------------- 1 | # This is here so we can keep YAML syntax highlight in the main file. 2 | AllCops: 3 | Exclude: 4 | - "lib/generators/**/templates/**/*" 5 | <% `git status --ignored --porcelain`.lines.grep(/^!! /).each do |path| %> 6 | - <%= path.sub(/^!! /, '').sub(/\/$/, '/**/*') %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private --private --protected --hide-void-return --markup markdown --fail-on-warning 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Pundit 2 | 3 | ## Unreleased 4 | 5 | ### Fixed 6 | - Requiring only `pundit/rspec` can raise an error in Active Support (#857) 7 | 8 | ## 2.5.0 (2025-03-03) 9 | 10 | ### Added 11 | 12 | - Add `Pundit::Authorization#pundit_reset!` hook to reset the policy and policy scope cache. [#830](https://github.com/varvet/pundit/issues/830) 13 | - Add links to gemspec. [#845](https://github.com/varvet/pundit/issues/845) 14 | - Register policies directories for Rails 8 code statistics [#833](https://github.com/varvet/pundit/issues/833) 15 | - Added an example for how to use pundit with Rails 8 authentication generator [#850](https://github.com/varvet/pundit/issues/850) 16 | 17 | ### Changed 18 | 19 | - Deprecated `Pundit::SUFFIX`, moved it to `Pundit::PolicyFinder::SUFFIX` [#835](https://github.com/varvet/pundit/issues/835) 20 | - Explicitly require less of `active_support` [#837](https://github.com/varvet/pundit/issues/837) 21 | - Using `permit` matcher without a surrouding `permissions` block now raises a useful error. [#836](https://github.com/varvet/pundit/issues/836) 22 | 23 | ### Fixed 24 | 25 | - Using a hash as custom cache in `Pundit.authorize` now works as documented. [#838](https://github.com/varvet/pundit/issues/838) 26 | 27 | ## 2.4.0 (2024-08-26) 28 | 29 | ### Changed 30 | 31 | - Improve the `NotAuthorizedError` message to include the policy class. 32 | Furthermore, in the case where the record passed is a class instead of an instance, the class name is given. [#812](https://github.com/varvet/pundit/issues/812) 33 | 34 | ### Added 35 | 36 | - Add customizable permit matcher description [#806](https://github.com/varvet/pundit/issues/806) 37 | - Add support for filter_run_when_matching :focus with permissions helper. [#820](https://github.com/varvet/pundit/issues/820) 38 | 39 | ## 2.3.2 (2024-05-08) 40 | 41 | - Refactor: First pass of Pundit::Context [#797](https://github.com/varvet/pundit/issues/797) 42 | 43 | ### Changed 44 | 45 | - Update `ApplicationPolicy` generator to qualify the `Scope` class name [#792](https://github.com/varvet/pundit/issues/792) 46 | - Policy generator uses `NoMethodError` to indicate `#resolve` is not implemented [#776](https://github.com/varvet/pundit/issues/776) 47 | 48 | ## Deprecated 49 | 50 | - Dropped support for Ruby 3.0 [#796](https://github.com/varvet/pundit/issues/796) 51 | 52 | ## 2.3.1 (2023-07-17) 53 | 54 | ### Fixed 55 | 56 | - Use `Kernel.warn` instead of `ActiveSupport::Deprecation.warn` for deprecations [#764](https://github.com/varvet/pundit/issues/764) 57 | - Policy generator now works on Ruby 3.2 [#754](https://github.com/varvet/pundit/issues/754) 58 | 59 | ## 2.3.0 (2022-12-19) 60 | 61 | ### Added 62 | 63 | - add support for rubocop-rspec syntax extensions [#745](https://github.com/varvet/pundit/issues/745) 64 | 65 | ## 2.2.0 (2022-02-11) 66 | 67 | ### Fixed 68 | 69 | - Using `policy_class` and a namespaced record now passes only the record when instantiating the policy. (#697, #689, #694, #666) 70 | 71 | ### Changed 72 | 73 | - Require users to explicitly define Scope#resolve in generated policies (#711, #722) 74 | 75 | ### Deprecated 76 | 77 | - Deprecate `include Pundit` in favor of `include Pundit::Authorization` [#621](https://github.com/varvet/pundit/issues/621) 78 | 79 | ## 2.1.1 (2021-08-13) 80 | 81 | Friday 13th-release! 82 | 83 | Careful! The bugfix below [#626](https://github.com/varvet/pundit/issues/626) could break existing code. If you rely on the 84 | return value for `authorize` and namespaced policies you might need to do some 85 | changes. 86 | 87 | ### Fixed 88 | 89 | - `.authorize` and `#authorize` return the instance, even for namespaced 90 | policies [#626](https://github.com/varvet/pundit/issues/626) 91 | 92 | ### Changed 93 | 94 | - Generate application scope with `protected` attr_readers. [#616](https://github.com/varvet/pundit/issues/616) 95 | 96 | ### Removed 97 | 98 | - Dropped support for Ruby end-of-life versions: 2.1 and 2.2. [#604](https://github.com/varvet/pundit/issues/604) 99 | - Dropped support for Ruby end-of-life versions: 2.3 [#633](https://github.com/varvet/pundit/issues/633) 100 | - Dropped support for Ruby end-of-life versions: 2.4, 2.5 and JRuby 9.1 [#676](https://github.com/varvet/pundit/issues/676) 101 | - Dropped support for RSpec 2 [#615](https://github.com/varvet/pundit/issues/615) 102 | 103 | ## 2.1.0 (2019-08-14) 104 | 105 | ### Fixed 106 | 107 | - Avoid name clashes with the Error class. [#590](https://github.com/varvet/pundit/issues/590) 108 | 109 | ### Changed 110 | 111 | - Return a safer default NotAuthorizedError message. [#583](https://github.com/varvet/pundit/issues/583) 112 | 113 | ## 2.0.1 (2019-01-18) 114 | 115 | ### Breaking changes 116 | 117 | None 118 | 119 | ### Other changes 120 | 121 | - Improve exception handling for `#policy_scope` and `#policy_scope!`. [#550](https://github.com/varvet/pundit/issues/550) 122 | - Add `:policy` metadata to RSpec template. [#566](https://github.com/varvet/pundit/issues/566) 123 | 124 | ## 2.0.0 (2018-07-21) 125 | 126 | No changes since beta1 127 | 128 | ## 2.0.0.beta1 (2018-07-04) 129 | 130 | ### Breaking changes 131 | 132 | - Only pass last element of "namespace array" to policy and scope. [#529](https://github.com/varvet/pundit/issues/529) 133 | - Raise `InvalidConstructorError` if a policy or policy scope with an invalid constructor is called. [#462](https://github.com/varvet/pundit/issues/462) 134 | - Return passed object from `#authorize` method to make chaining possible. [#385](https://github.com/varvet/pundit/issues/385) 135 | 136 | ### Other changes 137 | 138 | - Add `policy_class` option to `authorize` to be able to override the policy. [#441](https://github.com/varvet/pundit/issues/441) 139 | - Add `policy_scope_class` option to `authorize` to be able to override the policy scope. [#441](https://github.com/varvet/pundit/issues/441) 140 | - Fix `param_key` issue when passed an array. [#529](https://github.com/varvet/pundit/issues/529) 141 | - Allow specification of a `NilClassPolicy`. [#525](https://github.com/varvet/pundit/issues/525) 142 | - Make sure `policy_class` override is called when passed an array. [#475](https://github.com/varvet/pundit/issues/475) 143 | 144 | - Use `action_name` instead of `params[:action]`. [#419](https://github.com/varvet/pundit/issues/419) 145 | - Add `pundit_params_for` method to make it easy to customize params fetching. [#502](https://github.com/varvet/pundit/issues/502) 146 | 147 | ## 1.1.0 (2016-01-14) 148 | 149 | - Can retrieve policies via an array of symbols/objects. 150 | - Add autodetection of param key to `permitted_attributes` helper. 151 | - Hide some methods which should not be actions. 152 | - Permitted attributes should be expanded. 153 | - Generator uses `RSpec.describe` according to modern best practices. 154 | 155 | ## 1.0.1 (2015-05-27) 156 | 157 | - Fixed a regression where NotAuthorizedError could not be ininitialized with a string. 158 | - Use `camelize` instead of `classify` for symbol policies to prevent weird pluralizations. 159 | 160 | ## 1.0.0 (2015-04-19) 161 | 162 | - Caches policy scopes and policies. 163 | - Explicitly setting the policy for the controller via `controller.policy = foo` has been removed. Instead use `controller.policies[record] = foo`. 164 | - Explicitly setting the policy scope for the controller via `controller.policy_policy = foo` has been removed. Instead use `controller.policy_scopes[scope] = foo`. 165 | - Add `permitted_attributes` helper to fetch attributes from policy. 166 | - Add `pundit_policy_authorized?` and `pundit_policy_scoped?` methods. 167 | - Instance variables are prefixed to avoid collisions. 168 | - Add `Pundit.authorize` method. 169 | - Add `skip_authorization` and `skip_policy_scope` helpers. 170 | - Better errors when checking multiple permissions in RSpec tests. 171 | - Better errors in case `nil` is passed to `policy` or `policy_scope`. 172 | - Use `inspect` when printing object for better errors. 173 | - Dropped official support for Ruby 1.9.3 174 | 175 | ## 0.3.0 (2014-08-22) 176 | 177 | - Extend the default `ApplicationPolicy` with an `ApplicationPolicy::Scope` [#120](https://github.com/varvet/pundit/issues/120) 178 | - Fix RSpec 3 deprecation warnings for built-in matchers [#162](https://github.com/varvet/pundit/issues/162) 179 | - Generate blank policy spec/test files for Rspec/MiniTest/Test::Unit in Rails [#138](https://github.com/varvet/pundit/issues/138) 180 | 181 | ## 0.2.3 (2014-04-06) 182 | 183 | - Customizable error messages: `#query`, `#record` and `#policy` methods on `Pundit::NotAuthorizedError` [#114](https://github.com/varvet/pundit/issues/114) 184 | - Raise a different `Pundit::AuthorizationNotPerformedError` when `authorize` call is expected in controller action but missing [#109](https://github.com/varvet/pundit/issues/109) 185 | - Update Rspec matchers for Rspec 3 [#124](https://github.com/varvet/pundit/issues/124) 186 | 187 | ## 0.2.2 (2014-02-07) 188 | 189 | - Customize the user to be passed into policies: `pundit_user` [#42](https://github.com/varvet/pundit/issues/42) 190 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all 4 | people who contribute through reporting issues, posting feature requests, 5 | updating documentation, submitting pull requests or patches, and other 6 | activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, age, or religion. 12 | 13 | Examples of unacceptable behavior by participants include the use of sexual 14 | language or imagery, derogatory comments or personal attacks, trolling, public 15 | or private harassment, insults, or other unprofessional conduct. 16 | 17 | Project maintainers have the right and responsibility to remove, edit, or 18 | reject comments, commits, code, wiki edits, issues, and other contributions 19 | that are not aligned to this Code of Conduct. Project maintainers who do not 20 | follow the Code of Conduct may be removed from the project team. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 23 | reported by opening an issue or contacting one or more of the project 24 | maintainers. 25 | 26 | This Code of Conduct is adapted from the [Contributor 27 | Covenant](http:contributor-covenant.org), version 1.0.0, available at 28 | [https://contributor-covenant.org/version/1/0/0/](https://contributor-covenant.org/version/1/0/0/) 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Security issues 2 | 3 | If you have found a security related issue, please do not file an issue on GitHub or send a PR addressing the issue. Refer to [SECURITY.md](./SECURITY.md) for instructions. 4 | 5 | ## Reporting issues 6 | 7 | Please try to answer the following questions in your bug report: 8 | 9 | - What did you do? 10 | - What did you expect to happen? 11 | - What happened instead? 12 | 13 | Make sure to include as much relevant information as possible. Ruby version, 14 | Pundit version, OS version and any stack traces you have are very valuable. 15 | 16 | ## Pull Requests 17 | 18 | - **Add tests!** Your patch won't be accepted if it doesn't have tests. 19 | 20 | - **Document any change in behaviour**. Make sure the README and any other 21 | relevant documentation are kept up-to-date. 22 | 23 | - **Create topic branches**. Please don't ask us to pull from your main branch. 24 | 25 | - **One pull request per feature**. If you want to do more than one thing, send 26 | multiple pull requests. 27 | 28 | - **Send coherent history**. Make sure each individual commit in your pull 29 | request is meaningful. If you had to make multiple intermediate commits while 30 | developing, please squash them before sending them to us. 31 | - **Update the CHANGELOG.** Don't forget to add your new changes to the CHANGELOG. 32 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | # Rails-related - for testing purposes 8 | gem "actionpack", ">= 3.0.0" # Used to test strong parameters 9 | gem "activemodel", ">= 3.0.0" # Used to test ActiveModel::Naming 10 | gem "railties", ">= 3.0.0" # Used to test generators 11 | 12 | # Testing 13 | gem "rspec", ">= 3.0.0" 14 | gem "simplecov", ">= 0.17.0" 15 | 16 | # Development tools 17 | gem "bundler" 18 | gem "rake" 19 | gem "rubocop" 20 | gem "rubocop-performance" 21 | gem "rubocop-rspec" 22 | gem "yard" 23 | gem "zeitwerk" 24 | 25 | # Affects us on JRuby 9.3.15. 26 | # 27 | # @see https://github.com/rails/rails/issues/54260 28 | gem "logger" 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Jonas Nicklas, Varvet AB 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pundit 2 | 3 | [![Main](https://github.com/varvet/pundit/actions/workflows/main.yml/badge.svg)](https://github.com/varvet/pundit/actions/workflows/main.yml) 4 | [![Code Climate](https://api.codeclimate.com/v1/badges/a940030f96c9fb43046a/maintainability)](https://codeclimate.com/github/varvet/pundit/maintainability) 5 | [![Inline docs](https://inch-ci.org/github/varvet/pundit.svg?branch=main)](https://inch-ci.org/github/varvet/pundit) 6 | [![Gem Version](https://badge.fury.io/rb/pundit.svg)](https://badge.fury.io/rb/pundit) 7 | 8 | Pundit provides a set of helpers which guide you in leveraging regular Ruby 9 | classes and object oriented design patterns to build a straightforward, robust, and 10 | scalable authorization system. 11 | 12 | ## Links: 13 | 14 | - [API documentation for the most recent version](https://www.rubydoc.info/gems/pundit) 15 | - [Source Code](https://github.com/varvet/pundit) 16 | - [Contributing](https://github.com/varvet/pundit/blob/main/CONTRIBUTING.md) 17 | - [Code of Conduct](https://github.com/varvet/pundit/blob/main/CODE_OF_CONDUCT.md) 18 | 19 | Sponsored by: Varvet

Varvet logo 20 | 21 | ## Installation 22 | 23 | > **Please note** that the README on GitHub is accurate with the _latest code on GitHub_. You are most likely using a released version of Pundit, so please refer to the [documentation for the latest released version of Pundit](https://www.rubydoc.info/gems/pundit). 24 | 25 | ``` sh 26 | bundle add pundit 27 | ``` 28 | 29 | Include `Pundit::Authorization` in your application controller: 30 | 31 | ``` ruby 32 | class ApplicationController < ActionController::Base 33 | include Pundit::Authorization 34 | end 35 | ``` 36 | 37 | Optionally, you can run the generator, which will set up an application policy 38 | with some useful defaults for you: 39 | 40 | ``` sh 41 | rails g pundit:install 42 | ``` 43 | 44 | After generating your application policy, restart the Rails server so that Rails 45 | can pick up any classes in the new `app/policies/` directory. 46 | 47 | ## Policies 48 | 49 | Pundit is focused around the notion of policy classes. We suggest that you put 50 | these classes in `app/policies`. This is an example that allows updating a post 51 | if the user is an admin, or if the post is unpublished: 52 | 53 | ``` ruby 54 | class PostPolicy 55 | attr_reader :user, :post 56 | 57 | def initialize(user, post) 58 | @user = user 59 | @post = post 60 | end 61 | 62 | def update? 63 | user.admin? || !post.published? 64 | end 65 | end 66 | ``` 67 | 68 | As you can see, this is a plain Ruby class. Pundit makes the following 69 | assumptions about this class: 70 | 71 | - The class has the same name as some kind of model class, only suffixed 72 | with the word "Policy". 73 | - The first argument is a user. In your controller, Pundit will call the 74 | `current_user` method to retrieve what to send into this argument 75 | - The second argument is some kind of model object, whose authorization 76 | you want to check. This does not need to be an ActiveRecord or even 77 | an ActiveModel object, it can be anything really. 78 | - The class implements some kind of query method, in this case `update?`. 79 | Usually, this will map to the name of a particular controller action. 80 | 81 | That's it really. 82 | 83 | Usually you'll want to inherit from the application policy created by the 84 | generator, or set up your own base class to inherit from: 85 | 86 | ``` ruby 87 | class PostPolicy < ApplicationPolicy 88 | def update? 89 | user.admin? or not record.published? 90 | end 91 | end 92 | ``` 93 | 94 | In the generated `ApplicationPolicy`, the model object is called `record`. 95 | 96 | Supposing that you have an instance of class `Post`, Pundit now lets you do 97 | this in your controller: 98 | 99 | ``` ruby 100 | def update 101 | @post = Post.find(params[:id]) 102 | authorize @post 103 | if @post.update(post_params) 104 | redirect_to @post 105 | else 106 | render :edit 107 | end 108 | end 109 | ``` 110 | 111 | The authorize method automatically infers that `Post` will have a matching 112 | `PostPolicy` class, and instantiates this class, handing in the current user 113 | and the given record. It then infers from the action name, that it should call 114 | `update?` on this instance of the policy. In this case, you can imagine that 115 | `authorize` would have done something like this: 116 | 117 | ``` ruby 118 | unless PostPolicy.new(current_user, @post).update? 119 | raise Pundit::NotAuthorizedError, "not allowed to PostPolicy#update? this Post" 120 | end 121 | ``` 122 | 123 | You can pass a second argument to `authorize` if the name of the permission you 124 | want to check doesn't match the action name. For example: 125 | 126 | ``` ruby 127 | def publish 128 | @post = Post.find(params[:id]) 129 | authorize @post, :update? 130 | @post.publish! 131 | redirect_to @post 132 | end 133 | ``` 134 | 135 | You can pass an argument to override the policy class if necessary. For example: 136 | 137 | ```ruby 138 | def create 139 | @publication = find_publication # assume this method returns any model that behaves like a publication 140 | # @publication.class => Post 141 | authorize @publication, policy_class: PublicationPolicy 142 | @publication.publish! 143 | redirect_to @publication 144 | end 145 | ``` 146 | 147 | If you don't have an instance for the first argument to `authorize`, then you can pass 148 | the class. For example: 149 | 150 | Policy: 151 | ```ruby 152 | class PostPolicy < ApplicationPolicy 153 | def admin_list? 154 | user.admin? 155 | end 156 | end 157 | ``` 158 | 159 | Controller: 160 | ```ruby 161 | def admin_list 162 | authorize Post # we don't have a particular post to authorize 163 | # Rest of controller action 164 | end 165 | ``` 166 | 167 | `authorize` returns the instance passed to it, so you can chain it like this: 168 | 169 | Controller: 170 | ```ruby 171 | def show 172 | @user = authorize User.find(params[:id]) 173 | end 174 | 175 | # return the record even for namespaced policies 176 | def show 177 | @user = authorize [:admin, User.find(params[:id])] 178 | end 179 | ``` 180 | 181 | You can easily get a hold of an instance of the policy through the `policy` 182 | method in both the view and controller. This is especially useful for 183 | conditionally showing links or buttons in the view: 184 | 185 | ``` erb 186 | <% if policy(@post).update? %> 187 | <%= link_to "Edit post", edit_post_path(@post) %> 188 | <% end %> 189 | ``` 190 | ## Headless policies 191 | 192 | Given there is a policy without a corresponding model / ruby class, 193 | you can retrieve it by passing a symbol. 194 | 195 | ```ruby 196 | # app/policies/dashboard_policy.rb 197 | class DashboardPolicy 198 | attr_reader :user 199 | 200 | # `_record` in this example will be :dashboard 201 | def initialize(user, _record) 202 | @user = user 203 | end 204 | 205 | def show? 206 | user.admin? 207 | end 208 | end 209 | ``` 210 | 211 | Note that the headless policy still needs to accept two arguments. The 212 | second argument will be the symbol `:dashboard` in this case, which 213 | is what is passed as the record to `authorize` below. 214 | 215 | ```ruby 216 | # In controllers 217 | def show 218 | authorize :dashboard, :show? 219 | ... 220 | end 221 | ``` 222 | 223 | ```erb 224 | # In views 225 | <% if policy(:dashboard).show? %> 226 | <%= link_to 'Dashboard', dashboard_path %> 227 | <% end %> 228 | ``` 229 | 230 | ## Scopes 231 | 232 | Often, you will want to have some kind of view listing records which a 233 | particular user has access to. When using Pundit, you are expected to 234 | define a class called a policy scope. It can look something like this: 235 | 236 | ``` ruby 237 | class PostPolicy < ApplicationPolicy 238 | class Scope 239 | def initialize(user, scope) 240 | @user = user 241 | @scope = scope 242 | end 243 | 244 | def resolve 245 | if user.admin? 246 | scope.all 247 | else 248 | scope.where(published: true) 249 | end 250 | end 251 | 252 | private 253 | 254 | attr_reader :user, :scope 255 | end 256 | 257 | def update? 258 | user.admin? or not record.published? 259 | end 260 | end 261 | ``` 262 | 263 | Pundit makes the following assumptions about this class: 264 | 265 | - The class has the name `Scope` and is nested under the policy class. 266 | - The first argument is a user. In your controller, Pundit will call the 267 | `current_user` method to retrieve what to send into this argument. 268 | - The second argument is a scope of some kind on which to perform some kind of 269 | query. It will usually be an ActiveRecord class or a 270 | `ActiveRecord::Relation`, but it could be something else entirely. 271 | - Instances of this class respond to the method `resolve`, which should return 272 | some kind of result which can be iterated over. For ActiveRecord classes, 273 | this would usually be an `ActiveRecord::Relation`. 274 | 275 | You'll probably want to inherit from the application policy scope generated by the 276 | generator, or create your own base class to inherit from: 277 | 278 | ``` ruby 279 | class PostPolicy < ApplicationPolicy 280 | class Scope < ApplicationPolicy::Scope 281 | def resolve 282 | if user.admin? 283 | scope.all 284 | else 285 | scope.where(published: true) 286 | end 287 | end 288 | end 289 | 290 | def update? 291 | user.admin? or not record.published? 292 | end 293 | end 294 | ``` 295 | 296 | You can now use this class from your controller via the `policy_scope` method: 297 | 298 | ``` ruby 299 | def index 300 | @posts = policy_scope(Post) 301 | end 302 | 303 | def show 304 | @post = policy_scope(Post).find(params[:id]) 305 | end 306 | ``` 307 | 308 | Like with the authorize method, you can also override the policy scope class: 309 | 310 | ``` ruby 311 | def index 312 | # publication_class => Post 313 | @publications = policy_scope(publication_class, policy_scope_class: PublicationPolicy::Scope) 314 | end 315 | ``` 316 | 317 | In this case it is a shortcut for doing: 318 | 319 | ``` ruby 320 | def index 321 | @publications = PublicationPolicy::Scope.new(current_user, Post).resolve 322 | end 323 | ``` 324 | 325 | You can, and are encouraged to, use this method in views: 326 | 327 | ``` erb 328 | <% policy_scope(@user.posts).each do |post| %> 329 |

<%= link_to post.title, post_path(post) %>

330 | <% end %> 331 | ``` 332 | 333 | ## Ensuring policies and scopes are used 334 | 335 | When you are developing an application with Pundit it can be easy to forget to 336 | authorize some action. People are forgetful after all. Since Pundit encourages 337 | you to add the `authorize` call manually to each controller action, it's really 338 | easy to miss one. 339 | 340 | Thankfully, Pundit has a handy feature which reminds you in case you forget. 341 | Pundit tracks whether you have called `authorize` anywhere in your controller 342 | action. Pundit also adds a method to your controllers called 343 | `verify_authorized`. This method will raise an exception if `authorize` has not 344 | yet been called. You should run this method in an `after_action` hook to ensure 345 | that you haven't forgotten to authorize the action. For example: 346 | 347 | ``` ruby 348 | class ApplicationController < ActionController::Base 349 | include Pundit::Authorization 350 | after_action :verify_authorized 351 | end 352 | ``` 353 | 354 | Likewise, Pundit also adds `verify_policy_scoped` to your controller. This 355 | will raise an exception similar to `verify_authorized`. However, it tracks 356 | if `policy_scope` is used instead of `authorize`. This is mostly useful for 357 | controller actions like `index` which find collections with a scope and don't 358 | authorize individual instances. 359 | 360 | ``` ruby 361 | class ApplicationController < ActionController::Base 362 | include Pundit::Authorization 363 | after_action :verify_pundit_authorization 364 | 365 | def verify_pundit_authorization 366 | if action_name == "index" 367 | verify_policy_scoped 368 | else 369 | verify_authorized 370 | end 371 | end 372 | end 373 | ``` 374 | 375 | **This verification mechanism only exists to aid you while developing your 376 | application, so you don't forget to call `authorize`. It is not some kind of 377 | failsafe mechanism or authorization mechanism. You should be able to remove 378 | these filters without affecting how your app works in any way.** 379 | 380 | Some people have found this feature confusing, while many others 381 | find it extremely helpful. If you fall into the category of people who find it 382 | confusing then you do not need to use it. Pundit will work fine without 383 | using `verify_authorized` and `verify_policy_scoped`. 384 | 385 | ### Conditional verification 386 | 387 | If you're using `verify_authorized` in your controllers but need to 388 | conditionally bypass verification, you can use `skip_authorization`. For 389 | bypassing `verify_policy_scoped`, use `skip_policy_scope`. These are useful 390 | in circumstances where you don't want to disable verification for the 391 | entire action, but have some cases where you intend to not authorize. 392 | 393 | ```ruby 394 | def show 395 | record = Record.find_by(attribute: "value") 396 | if record.present? 397 | authorize record 398 | else 399 | skip_authorization 400 | end 401 | end 402 | ``` 403 | 404 | ## Manually specifying policy classes 405 | 406 | Sometimes you might want to explicitly declare which policy to use for a given 407 | class, instead of letting Pundit infer it. This can be done like so: 408 | 409 | ``` ruby 410 | class Post 411 | def self.policy_class 412 | PostablePolicy 413 | end 414 | end 415 | ``` 416 | 417 | Alternatively, you can declare an instance method: 418 | 419 | ``` ruby 420 | class Post 421 | def policy_class 422 | PostablePolicy 423 | end 424 | end 425 | ``` 426 | 427 | ## Plain old Ruby 428 | 429 | Pundit is a very small library on purpose, and it doesn't do anything you can't do yourself. There's no secret sauce here. It does as little as possible, and then gets out of your way. 430 | 431 | With the few but powerful helpers available in Pundit, you have the power to build a well structured, fully working authorization system without using any special DSLs or funky syntax. 432 | 433 | Remember that all of the policy and scope classes are plain Ruby classes, which means you can use the same mechanisms you always use to DRY things up. Encapsulate a set of permissions into a module and include them in multiple policies. Use `alias_method` to make some permissions behave the same as others. Inherit from a base set of permissions. Use metaprogramming if you really have to. 434 | 435 | ## Generator 436 | 437 | Use the supplied generator to generate policies: 438 | 439 | ``` sh 440 | rails g pundit:policy post 441 | ``` 442 | 443 | ## Closed systems 444 | 445 | In many applications, only logged in users are really able to do anything. If 446 | you're building such a system, it can be kind of cumbersome to check that the 447 | user in a policy isn't `nil` for every single permission. Aside from policies, 448 | you can add this check to the base class for scopes. 449 | 450 | We suggest that you define a filter that redirects unauthenticated users to the 451 | login page. As a secondary defence, if you've defined an ApplicationPolicy, it 452 | might be a good idea to raise an exception if somehow an unauthenticated user 453 | got through. This way you can fail more gracefully. 454 | 455 | ``` ruby 456 | class ApplicationPolicy 457 | def initialize(user, record) 458 | raise Pundit::NotAuthorizedError, "must be logged in" unless user 459 | @user = user 460 | @record = record 461 | end 462 | 463 | class Scope 464 | attr_reader :user, :scope 465 | 466 | def initialize(user, scope) 467 | raise Pundit::NotAuthorizedError, "must be logged in" unless user 468 | @user = user 469 | @scope = scope 470 | end 471 | end 472 | end 473 | ``` 474 | 475 | ## NilClassPolicy 476 | 477 | To support a [null object pattern](https://en.wikipedia.org/wiki/Null_Object_pattern) 478 | you may find that you want to implement a `NilClassPolicy`. This might be useful 479 | where you want to extend your ApplicationPolicy to allow some tolerance of, for 480 | example, associations which might be `nil`. 481 | 482 | ```ruby 483 | class NilClassPolicy < ApplicationPolicy 484 | class Scope < ApplicationPolicy::Scope 485 | def resolve 486 | raise Pundit::NotDefinedError, "Cannot scope NilClass" 487 | end 488 | end 489 | 490 | def show? 491 | false # Nobody can see nothing 492 | end 493 | end 494 | ``` 495 | 496 | ## Rescuing a denied Authorization in Rails 497 | 498 | Pundit raises a `Pundit::NotAuthorizedError` you can 499 | [rescue_from](https://guides.rubyonrails.org/action_controller_overview.html#rescue-from) 500 | in your `ApplicationController`. You can customize the `user_not_authorized` 501 | method in every controller. 502 | 503 | ```ruby 504 | class ApplicationController < ActionController::Base 505 | include Pundit::Authorization 506 | 507 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 508 | 509 | private 510 | 511 | def user_not_authorized 512 | flash[:alert] = "You are not authorized to perform this action." 513 | redirect_back_or_to(root_path) 514 | end 515 | end 516 | ``` 517 | 518 | Alternatively, you can globally handle Pundit::NotAuthorizedError's by having rails handle them as a 403 error and serving a 403 error page. Add the following to application.rb: 519 | 520 | ```config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden``` 521 | 522 | ## Creating custom error messages 523 | 524 | `NotAuthorizedError`s provide information on what query (e.g. `:create?`), what 525 | record (e.g. an instance of `Post`), and what policy (e.g. an instance of 526 | `PostPolicy`) caused the error to be raised. 527 | 528 | One way to use these `query`, `record`, and `policy` properties is to connect 529 | them with `I18n` to generate error messages. Here's how you might go about doing 530 | that. 531 | 532 | ```ruby 533 | class ApplicationController < ActionController::Base 534 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 535 | 536 | private 537 | 538 | def user_not_authorized(exception) 539 | policy_name = exception.policy.class.to_s.underscore 540 | 541 | flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default 542 | redirect_back_or_to(root_path) 543 | end 544 | end 545 | ``` 546 | 547 | ```yaml 548 | en: 549 | pundit: 550 | default: 'You cannot perform this action.' 551 | post_policy: 552 | update?: 'You cannot edit this post!' 553 | create?: 'You cannot create posts!' 554 | ``` 555 | 556 | This is an example. Pundit is agnostic as to how you implement your error messaging. 557 | 558 | ## Manually retrieving policies and scopes 559 | 560 | Sometimes you want to retrieve a policy for a record outside the controller or 561 | view. For example when you delegate permissions from one policy to another. 562 | 563 | You can easily retrieve policies and scopes like this: 564 | 565 | ``` ruby 566 | Pundit.policy!(user, post) 567 | Pundit.policy(user, post) 568 | 569 | Pundit.policy_scope!(user, Post) 570 | Pundit.policy_scope(user, Post) 571 | ``` 572 | 573 | The bang methods will raise an exception if the policy does not exist, whereas 574 | those without the bang will return nil. 575 | 576 | ## Customize Pundit user 577 | 578 | On occasion, your controller may be unable to access `current_user`, or the method that should be invoked by Pundit may not be `current_user`. To address this, you can define a method in your controller named `pundit_user`. 579 | 580 | ```ruby 581 | def pundit_user 582 | User.find_by_other_means 583 | end 584 | ``` 585 | 586 | For instance, Rails 8 includes a built-in [authentication generator](https://github.com/rails/rails/tree/8-0-stable/railties/lib/rails/generators/rails/authentication). If you choose to use it, the currently logged-in user is accessed via `Current.user` instead of `current_user`. 587 | 588 | To ensure compatibility with Pundit, define a `pundit_user` method in `application_controller.rb` (or another suitable location) as follows: 589 | 590 | ```ruby 591 | def pundit_user 592 | Current.user 593 | end 594 | ``` 595 | 596 | ### Handling User Switching in Pundit 597 | 598 | When switching users in your application, it's important to reset the Pundit user context to ensure that authorization policies are applied correctly for the new user. Pundit caches the user context, so failing to reset it could result in incorrect permissions being applied. 599 | 600 | To handle user switching, you can use the following pattern in your controller: 601 | 602 | ```ruby 603 | class ApplicationController 604 | include Pundit::Authorization 605 | 606 | def switch_user_to(user) 607 | terminate_session if authenticated? 608 | start_new_session_for user 609 | pundit_reset! 610 | end 611 | end 612 | ``` 613 | 614 | Make sure to invoke `pundit_reset!` whenever changing the user. This ensures the cached authorization context is reset, preventing any incorrect permissions from being applied. 615 | 616 | ## Policy Namespacing 617 | In some cases it might be helpful to have multiple policies that serve different contexts for a 618 | resource. A prime example of this is the case where User policies differ from Admin policies. To 619 | authorize with a namespaced policy, pass the namespace into the `authorize` helper in an array: 620 | 621 | ```ruby 622 | authorize(post) # => will look for a PostPolicy 623 | authorize([:admin, post]) # => will look for an Admin::PostPolicy 624 | authorize([:foo, :bar, post]) # => will look for a Foo::Bar::PostPolicy 625 | 626 | policy_scope(Post) # => will look for a PostPolicy::Scope 627 | policy_scope([:admin, Post]) # => will look for an Admin::PostPolicy::Scope 628 | policy_scope([:foo, :bar, Post]) # => will look for a Foo::Bar::PostPolicy::Scope 629 | ``` 630 | 631 | If you are using namespaced policies for something like Admin views, it can be useful to 632 | override the `policy_scope` and `authorize` helpers in your `AdminController` to automatically 633 | apply the namespacing: 634 | 635 | ```ruby 636 | class AdminController < ApplicationController 637 | def policy_scope(scope) 638 | super([:admin, scope]) 639 | end 640 | 641 | def authorize(record, query = nil) 642 | super([:admin, record], query) 643 | end 644 | end 645 | 646 | class Admin::PostController < AdminController 647 | def index 648 | policy_scope(Post) 649 | end 650 | 651 | def show 652 | post = authorize Post.find(params[:id]) 653 | end 654 | end 655 | ``` 656 | 657 | ## Additional context 658 | 659 | Pundit strongly encourages you to model your application in such a way that the 660 | only context you need for authorization is a user object and a domain model that 661 | you want to check authorization for. If you find yourself needing more context than 662 | that, consider whether you are authorizing the right domain model, maybe another 663 | domain model (or a wrapper around multiple domain models) can provide the context 664 | you need. 665 | 666 | Pundit does not allow you to pass additional arguments to policies for precisely 667 | this reason. 668 | 669 | However, in very rare cases, you might need to authorize based on more context than just 670 | the currently authenticated user. Suppose for example that authorization is dependent 671 | on IP address in addition to the authenticated user. In that case, one option is to 672 | create a special class which wraps up both user and IP and passes it to the policy. 673 | 674 | ``` ruby 675 | class UserContext 676 | attr_reader :user, :ip 677 | 678 | def initialize(user, ip) 679 | @user = user 680 | @ip = ip 681 | end 682 | end 683 | 684 | class ApplicationController 685 | include Pundit::Authorization 686 | 687 | def pundit_user 688 | UserContext.new(current_user, request.ip) 689 | end 690 | end 691 | ``` 692 | 693 | ## Strong parameters 694 | 695 | In Rails, 696 | mass-assignment protection is handled in the controller. With Pundit you can 697 | control which attributes a user has access to update via your policies. You can 698 | set up a `permitted_attributes` method in your policy like this: 699 | 700 | ```ruby 701 | # app/policies/post_policy.rb 702 | class PostPolicy < ApplicationPolicy 703 | def permitted_attributes 704 | if user.admin? || user.owner_of?(post) 705 | [:title, :body, :tag_list] 706 | else 707 | [:tag_list] 708 | end 709 | end 710 | end 711 | ``` 712 | 713 | You can now retrieve these attributes from the policy: 714 | 715 | ```ruby 716 | # app/controllers/posts_controller.rb 717 | class PostsController < ApplicationController 718 | def update 719 | @post = Post.find(params[:id]) 720 | if @post.update(post_params) 721 | redirect_to @post 722 | else 723 | render :edit 724 | end 725 | end 726 | 727 | private 728 | 729 | def post_params 730 | params.require(:post).permit(policy(@post).permitted_attributes) 731 | end 732 | end 733 | ``` 734 | 735 | However, this is a bit cumbersome, so Pundit provides a convenient helper method: 736 | 737 | ```ruby 738 | # app/controllers/posts_controller.rb 739 | class PostsController < ApplicationController 740 | def update 741 | @post = Post.find(params[:id]) 742 | if @post.update(permitted_attributes(@post)) 743 | redirect_to @post 744 | else 745 | render :edit 746 | end 747 | end 748 | end 749 | ``` 750 | 751 | If you want to permit different attributes based on the current action, you can define a `permitted_attributes_for_#{action}` method on your policy: 752 | 753 | ```ruby 754 | # app/policies/post_policy.rb 755 | class PostPolicy < ApplicationPolicy 756 | def permitted_attributes_for_create 757 | [:title, :body] 758 | end 759 | 760 | def permitted_attributes_for_edit 761 | [:body] 762 | end 763 | end 764 | ``` 765 | 766 | If you have defined an action-specific method on your policy for the current action, the `permitted_attributes` helper will call it instead of calling `permitted_attributes` on your controller. 767 | 768 | If you need to fetch parameters based on namespaces different from the suggested one, override the below method, in your controller, and return an instance of `ActionController::Parameters`. 769 | 770 | ```ruby 771 | def pundit_params_for(record) 772 | params.require(PolicyFinder.new(record).param_key) 773 | end 774 | ``` 775 | 776 | For example: 777 | 778 | ```ruby 779 | # If you don't want to use require 780 | def pundit_params_for(record) 781 | params.fetch(PolicyFinder.new(record).param_key, {}) 782 | end 783 | 784 | # If you are using something like the JSON API spec 785 | def pundit_params_for(_record) 786 | params.fetch(:data, {}).fetch(:attributes, {}) 787 | end 788 | ``` 789 | 790 | ## RSpec 791 | 792 | ### Policy Specs 793 | 794 | > [!TIP] 795 | > An alternative approach to Pundit policy specs is scoping them to a user context as outlined in this 796 | [excellent post](https://thunderboltlabs.com/blog/2013/03/27/testing-pundit-policies-with-rspec/) and implemented in the third party [pundit-matchers](https://github.com/punditcommunity/pundit-matchers) gem. 797 | 798 | Pundit includes a mini-DSL for writing expressive tests for your policies in RSpec. 799 | Require `pundit/rspec` in your `spec_helper.rb`: 800 | 801 | ``` ruby 802 | require "pundit/rspec" 803 | ``` 804 | 805 | Then put your policy specs in `spec/policies`, and make them look somewhat like this: 806 | 807 | ``` ruby 808 | describe PostPolicy do 809 | subject { described_class } 810 | 811 | permissions :update?, :edit? do 812 | it "denies access if post is published" do 813 | expect(subject).not_to permit(User.new(admin: false), Post.new(published: true)) 814 | end 815 | 816 | it "grants access if post is published and user is an admin" do 817 | expect(subject).to permit(User.new(admin: true), Post.new(published: true)) 818 | end 819 | 820 | it "grants access if post is unpublished" do 821 | expect(subject).to permit(User.new(admin: false), Post.new(published: false)) 822 | end 823 | end 824 | end 825 | ``` 826 | 827 | ### Custom matcher description 828 | 829 | By default rspec includes an inspected `user` and `record` in the matcher description, which might become overly verbose: 830 | 831 | ``` 832 | PostPolicy 833 | update? and show? 834 | is expected to permit # and #> 835 | ``` 836 | 837 | You can override the default description with a static string, or a block: 838 | 839 | ```ruby 840 | # static alternative: Pundit::RSpec::Matchers.description = "permit the user" 841 | Pundit::RSpec::Matchers.description = ->(user, record) do 842 | "permit user with role #{user.role} to access record with ID #{record.id}" 843 | end 844 | ``` 845 | 846 | Which would make for a less chatty output: 847 | 848 | ``` 849 | PostPolicy 850 | update? and show? 851 | is expected to permit user with role admin to access record with ID 130 852 | ``` 853 | 854 | ### Focus Support 855 | 856 | If your RSpec config has `filter_run_when_matching :focus`, you may tag the `permissions` helper like so: 857 | 858 | ``` 859 | permissions :show?, :focus do 860 | ``` 861 | 862 | ### Scope Specs 863 | 864 | Pundit does not provide a DSL for testing scopes. Test them like you would a regular Ruby class! 865 | 866 | ### Linting with RuboCop RSpec 867 | 868 | When you lint your RSpec spec files with `rubocop-rspec`, it will fail to properly detect RSpec constructs that Pundit defines, `permissions`. 869 | Make sure to use `rubocop-rspec` 2.0 or newer and add the following to your `.rubocop.yml`: 870 | 871 | ```yaml 872 | inherit_gem: 873 | pundit: config/rubocop-rspec.yml 874 | ``` 875 | 876 | # External Resources 877 | 878 | - [RailsApps Example Application: Pundit and Devise](https://github.com/RailsApps/rails-devise-pundit) 879 | - [Migrating to Pundit from CanCan](https://blog.carbonfive.com/2013/10/21/migrating-to-pundit-from-cancan/) 880 | - [Testing Pundit Policies with RSpec](https://thunderboltlabs.com/blog/2013/03/27/testing-pundit-policies-with-rspec/) 881 | - [Testing Pundit with Minitest](https://github.com/varvet/pundit/issues/204#issuecomment-60166450) 882 | - [Using Pundit outside of a Rails controller](https://github.com/varvet/pundit/pull/136) 883 | - [Straightforward Rails Authorization with Pundit](https://www.sitepoint.com/straightforward-rails-authorization-with-pundit/) 884 | 885 | ## Other implementations 886 | 887 | - [Flask-Pundit](https://github.com/anurag90x/flask-pundit) (Python) is a [Flask](https://flask.pocoo.org/) extension "heavily inspired by" Pundit 888 | 889 | # License 890 | 891 | Licensed under the MIT license, see the separate LICENSE.txt file. 892 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" 4 | require "bundler/gem_tasks" 5 | require "rspec/core/rake_task" 6 | require "yard" 7 | require "rubocop/rake_task" 8 | 9 | RuboCop::RakeTask.new 10 | 11 | desc "Run all examples" 12 | RSpec::Core::RakeTask.new(:spec) do |t| 13 | t.rspec_opts = %w[--color] 14 | end 15 | 16 | YARD::Rake::YardocTask.new do |t| 17 | t.files = ["lib/**/*.rb"] 18 | t.stats_options = ["--list-undoc"] 19 | end 20 | 21 | task default: :spec 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Please do not file an issue on GitHub, or send a PR addressing the issue. 4 | 5 | ## Supported versions 6 | 7 | Most recent major version only. 8 | 9 | ## Reporting a vulnerability 10 | 11 | Contact one of the maintainers directly: 12 | 13 | * [@Burgestrand](https://github.com/Burgestrand) 14 | * [@dgmstuart](https://github.com/dgmstuart) 15 | * [@varvet](https://github.com/varvet) 16 | 17 | You can report vulnerabilities on GitHub too: https://github.com/varvet/pundit/security 18 | 19 | Thank you! 20 | -------------------------------------------------------------------------------- /config/rubocop-rspec.yml: -------------------------------------------------------------------------------- 1 | RSpec: 2 | Language: 3 | ExampleGroups: 4 | Regular: 5 | - permissions 6 | -------------------------------------------------------------------------------- /lib/generators/pundit/install/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates an application policy as a starting point for your application. 3 | -------------------------------------------------------------------------------- /lib/generators/pundit/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # @private 5 | module Generators 6 | # @private 7 | class InstallGenerator < ::Rails::Generators::Base 8 | source_root File.expand_path("templates", __dir__) 9 | 10 | def copy_application_policy 11 | template "application_policy.rb.tt", "app/policies/application_policy.rb" 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/generators/pundit/install/templates/application_policy.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationPolicy 4 | attr_reader :user, :record 5 | 6 | def initialize(user, record) 7 | @user = user 8 | @record = record 9 | end 10 | 11 | def index? 12 | false 13 | end 14 | 15 | def show? 16 | false 17 | end 18 | 19 | def create? 20 | false 21 | end 22 | 23 | def new? 24 | create? 25 | end 26 | 27 | def update? 28 | false 29 | end 30 | 31 | def edit? 32 | update? 33 | end 34 | 35 | def destroy? 36 | false 37 | end 38 | 39 | class Scope 40 | def initialize(user, scope) 41 | @user = user 42 | @scope = scope 43 | end 44 | 45 | def resolve 46 | raise NoMethodError, "You must define #resolve in #{self.class}" 47 | end 48 | 49 | private 50 | 51 | attr_reader :user, :scope 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/generators/pundit/policy/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates a policy for a model with the given name. 3 | 4 | Example: 5 | rails generate pundit:policy user 6 | 7 | This will create: 8 | app/policies/user_policy.rb 9 | -------------------------------------------------------------------------------- /lib/generators/pundit/policy/policy_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # @private 5 | module Generators 6 | # @private 7 | class PolicyGenerator < ::Rails::Generators::NamedBase 8 | source_root File.expand_path("templates", __dir__) 9 | 10 | def create_policy 11 | template "policy.rb.tt", File.join("app/policies", class_path, "#{file_name}_policy.rb") 12 | end 13 | 14 | hook_for :test_framework 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/generators/pundit/policy/templates/policy.rb.tt: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name %>Policy < ApplicationPolicy 3 | # NOTE: Up to Pundit v2.3.1, the inheritance was declared as 4 | # `Scope < Scope` rather than `Scope < ApplicationPolicy::Scope`. 5 | # In most cases the behavior will be identical, but if updating existing 6 | # code, beware of possible changes to the ancestors: 7 | # https://gist.github.com/Burgestrand/4b4bc22f31c8a95c425fc0e30d7ef1f5 8 | 9 | class Scope < ApplicationPolicy::Scope 10 | # NOTE: Be explicit about which records you allow access to! 11 | # def resolve 12 | # scope.all 13 | # end 14 | end 15 | end 16 | <% end -%> 17 | -------------------------------------------------------------------------------- /lib/generators/rspec/policy_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # @private 4 | module Rspec 5 | # @private 6 | module Generators 7 | # @private 8 | class PolicyGenerator < ::Rails::Generators::NamedBase 9 | source_root File.expand_path("templates", __dir__) 10 | 11 | def create_policy_spec 12 | template "policy_spec.rb.tt", File.join("spec/policies", class_path, "#{file_name}_policy_spec.rb") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/rspec/templates/policy_spec.rb.tt: -------------------------------------------------------------------------------- 1 | require '<%= File.exist?('spec/rails_helper.rb') ? 'rails_helper' : 'spec_helper' %>' 2 | 3 | RSpec.describe <%= class_name %>Policy, type: :policy do 4 | let(:user) { User.new } 5 | 6 | subject { described_class } 7 | 8 | permissions ".scope" do 9 | pending "add some examples to (or delete) #{__FILE__}" 10 | end 11 | 12 | permissions :show? do 13 | pending "add some examples to (or delete) #{__FILE__}" 14 | end 15 | 16 | permissions :create? do 17 | pending "add some examples to (or delete) #{__FILE__}" 18 | end 19 | 20 | permissions :update? do 21 | pending "add some examples to (or delete) #{__FILE__}" 22 | end 23 | 24 | permissions :destroy? do 25 | pending "add some examples to (or delete) #{__FILE__}" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/test_unit/policy_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # @private 4 | module TestUnit 5 | # @private 6 | module Generators 7 | # @private 8 | class PolicyGenerator < ::Rails::Generators::NamedBase 9 | source_root File.expand_path("templates", __dir__) 10 | 11 | def create_policy_test 12 | template "policy_test.rb.tt", File.join("test/policies", class_path, "#{file_name}_policy_test.rb") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/test_unit/templates/policy_test.rb.tt: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class <%= class_name %>PolicyTest < ActiveSupport::TestCase 4 | def test_scope 5 | end 6 | 7 | def test_show 8 | end 9 | 10 | def test_create 11 | end 12 | 13 | def test_update 14 | end 15 | 16 | def test_destroy 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/pundit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | 5 | require "pundit/version" 6 | require "pundit/error" 7 | require "pundit/policy_finder" 8 | require "pundit/context" 9 | require "pundit/authorization" 10 | require "pundit/helper" 11 | require "pundit/cache_store" 12 | require "pundit/cache_store/null_store" 13 | require "pundit/cache_store/legacy_store" 14 | require "pundit/railtie" if defined?(Rails) 15 | 16 | # Hello? Yes, this is Pundit. 17 | # 18 | # @api public 19 | module Pundit 20 | # @api private 21 | # @since v1.0.0 22 | # @deprecated See {Pundit::PolicyFinder} 23 | SUFFIX = Pundit::PolicyFinder::SUFFIX 24 | 25 | # @api private 26 | # @private 27 | # @since v0.1.0 28 | module Generators; end 29 | 30 | def self.included(base) 31 | location = caller_locations(1, 1).first 32 | warn <<~WARNING 33 | 'include Pundit' is deprecated. Please use 'include Pundit::Authorization' instead. 34 | (called from #{location.label} at #{location.path}:#{location.lineno}) 35 | WARNING 36 | base.include Authorization 37 | end 38 | 39 | class << self 40 | # @see Pundit::Context#authorize 41 | # @since v1.0.0 42 | def authorize(user, record, query, policy_class: nil, cache: nil) 43 | context = if cache 44 | policy_cache = CacheStore::LegacyStore.new(cache) 45 | Context.new(user: user, policy_cache: policy_cache) 46 | else 47 | Context.new(user: user) 48 | end 49 | 50 | context.authorize(record, query: query, policy_class: policy_class) 51 | end 52 | 53 | # @see Pundit::Context#policy_scope 54 | # @since v0.1.0 55 | def policy_scope(user, *args, **kwargs, &block) 56 | Context.new(user: user).policy_scope(*args, **kwargs, &block) 57 | end 58 | 59 | # @see Pundit::Context#policy_scope! 60 | # @since v0.1.0 61 | def policy_scope!(user, *args, **kwargs, &block) 62 | Context.new(user: user).policy_scope!(*args, **kwargs, &block) 63 | end 64 | 65 | # @see Pundit::Context#policy 66 | # @since v0.1.0 67 | def policy(user, *args, **kwargs, &block) 68 | Context.new(user: user).policy(*args, **kwargs, &block) 69 | end 70 | 71 | # @see Pundit::Context#policy! 72 | # @since v0.1.0 73 | def policy!(user, *args, **kwargs, &block) 74 | Context.new(user: user).policy!(*args, **kwargs, &block) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/pundit/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # Pundit DSL to include in your controllers to provide authorization helpers. 5 | # 6 | # @example 7 | # class ApplicationController < ActionController::Base 8 | # include Pundit::Authorization 9 | # end 10 | # @see #pundit 11 | # @api public 12 | # @since v2.2.0 13 | module Authorization 14 | extend ActiveSupport::Concern 15 | 16 | included do 17 | helper Helper if respond_to?(:helper) 18 | if respond_to?(:helper_method) 19 | helper_method :policy 20 | helper_method :pundit_policy_scope 21 | helper_method :pundit_user 22 | end 23 | end 24 | 25 | protected 26 | 27 | # An instance of {Pundit::Context} initialized with the current user. 28 | # 29 | # @note this method is memoized and will return the same instance during the request. 30 | # @api public 31 | # @return [Pundit::Context] 32 | # @see #pundit_user 33 | # @see #policies 34 | # @since v2.3.2 35 | def pundit 36 | @pundit ||= Pundit::Context.new( 37 | user: pundit_user, 38 | policy_cache: Pundit::CacheStore::LegacyStore.new(policies) 39 | ) 40 | end 41 | 42 | # Hook method which allows customizing which user is passed to policies and 43 | # scopes initialized by {#authorize}, {#policy} and {#policy_scope}. 44 | # 45 | # @note Make sure to call `pundit_reset!` if this changes during a request. 46 | # @see https://github.com/varvet/pundit#customize-pundit-user 47 | # @see #pundit 48 | # @see #pundit_reset! 49 | # @return [Object] the user object to be used with pundit 50 | # @since v0.2.2 51 | def pundit_user 52 | current_user 53 | end 54 | 55 | # Clears the cached Pundit authorization data. 56 | # 57 | # This method should be called when the pundit_user is changed, 58 | # such as during user switching, to ensure that stale authorization 59 | # data is not used. Pundit caches authorization policies and scopes 60 | # for the pundit_user, so calling this method will reset those 61 | # caches and ensure that the next authorization checks are performed 62 | # with the correct context for the new pundit_user. 63 | # 64 | # @return [void] 65 | # @since v2.5.0 66 | def pundit_reset! 67 | @pundit = nil 68 | @_pundit_policies = nil 69 | @_pundit_policy_scopes = nil 70 | @_pundit_policy_authorized = nil 71 | @_pundit_policy_scoped = nil 72 | end 73 | 74 | # @!group Policies 75 | 76 | # Retrieves the policy for the given record, initializing it with the record 77 | # and current user and finally throwing an error if the user is not 78 | # authorized to perform the given action. 79 | # 80 | # @param record [Object, Array] the object we're checking permissions of 81 | # @param query [Symbol, String] the predicate method to check on the policy (e.g. `:show?`). 82 | # If omitted then this defaults to the Rails controller action name. 83 | # @param policy_class [Class] the policy class we want to force use of 84 | # @raise [NotAuthorizedError] if the given query method returned false 85 | # @return [record] Always returns the passed object record 86 | # @see Pundit::Context#authorize 87 | # @see #verify_authorized 88 | # @since v0.1.0 89 | def authorize(record, query = nil, policy_class: nil) 90 | query ||= "#{action_name}?" 91 | 92 | @_pundit_policy_authorized = true 93 | 94 | pundit.authorize(record, query: query, policy_class: policy_class) 95 | end 96 | 97 | # Allow this action not to perform authorization. 98 | # 99 | # @see https://github.com/varvet/pundit#ensuring-policies-and-scopes-are-used 100 | # @return [void] 101 | # @see #verify_authorized 102 | # @since v1.0.0 103 | def skip_authorization 104 | @_pundit_policy_authorized = :skipped 105 | end 106 | 107 | # @return [Boolean] wether or not authorization has been performed 108 | # @see #authorize 109 | # @see #skip_authorization 110 | # @since v1.0.0 111 | def pundit_policy_authorized? 112 | !!@_pundit_policy_authorized 113 | end 114 | 115 | # Raises an error if authorization has not been performed. 116 | # 117 | # Usually used as an `after_action` filter to prevent programmer error in 118 | # forgetting to call {#authorize} or {#skip_authorization}. 119 | # 120 | # @see https://github.com/varvet/pundit#ensuring-policies-and-scopes-are-used 121 | # @raise [AuthorizationNotPerformedError] if authorization has not been performed 122 | # @return [void] 123 | # @see #authorize 124 | # @see #skip_authorization 125 | # @since v0.1.0 126 | def verify_authorized 127 | raise AuthorizationNotPerformedError, self.class unless pundit_policy_authorized? 128 | end 129 | 130 | # rubocop:disable Naming/MemoizedInstanceVariableName 131 | 132 | # Cache of policies. You should not rely on this method. 133 | # 134 | # @api private 135 | # @since v1.0.0 136 | def policies 137 | @_pundit_policies ||= {} 138 | end 139 | 140 | # rubocop:enable Naming/MemoizedInstanceVariableName 141 | 142 | # @!endgroup 143 | 144 | # Retrieves the policy for the given record. 145 | # 146 | # @see https://github.com/varvet/pundit#policies 147 | # @param record [Object] the object we're retrieving the policy for 148 | # @return [Object] instance of policy class with query methods 149 | # @since v0.1.0 150 | def policy(record) 151 | pundit.policy!(record) 152 | end 153 | 154 | # @!group Policy Scopes 155 | 156 | # Retrieves the policy scope for the given record. 157 | # 158 | # @see https://github.com/varvet/pundit#scopes 159 | # @param scope [Object] the object we're retrieving the policy scope for 160 | # @param policy_scope_class [#resolve] the policy scope class we want to force use of 161 | # @return [#resolve, nil] instance of scope class which can resolve to a scope 162 | # @since v0.1.0 163 | def policy_scope(scope, policy_scope_class: nil) 164 | @_pundit_policy_scoped = true 165 | policy_scope_class ? policy_scope_class.new(pundit_user, scope).resolve : pundit_policy_scope(scope) 166 | end 167 | 168 | # Allow this action not to perform policy scoping. 169 | # 170 | # @see https://github.com/varvet/pundit#ensuring-policies-and-scopes-are-used 171 | # @return [void] 172 | # @see #verify_policy_scoped 173 | # @since v1.0.0 174 | def skip_policy_scope 175 | @_pundit_policy_scoped = :skipped 176 | end 177 | 178 | # @return [Boolean] wether or not policy scoping has been performed 179 | # @see #policy_scope 180 | # @see #skip_policy_scope 181 | # @since v1.0.0 182 | def pundit_policy_scoped? 183 | !!@_pundit_policy_scoped 184 | end 185 | 186 | # Raises an error if policy scoping has not been performed. 187 | # 188 | # Usually used as an `after_action` filter to prevent programmer error in 189 | # forgetting to call {#policy_scope} or {#skip_policy_scope} in index 190 | # actions. 191 | # 192 | # @see https://github.com/varvet/pundit#ensuring-policies-and-scopes-are-used 193 | # @raise [AuthorizationNotPerformedError] if policy scoping has not been performed 194 | # @return [void] 195 | # @see #policy_scope 196 | # @see #skip_policy_scope 197 | # @since v0.2.1 198 | def verify_policy_scoped 199 | raise PolicyScopingNotPerformedError, self.class unless pundit_policy_scoped? 200 | end 201 | 202 | # rubocop:disable Naming/MemoizedInstanceVariableName 203 | 204 | # Cache of policy scope. You should not rely on this method. 205 | # 206 | # @api private 207 | # @since v1.0.0 208 | def policy_scopes 209 | @_pundit_policy_scopes ||= {} 210 | end 211 | 212 | # rubocop:enable Naming/MemoizedInstanceVariableName 213 | 214 | # This was added to allow calling `policy_scope!` without flipping the 215 | # `pundit_policy_scoped?` flag. 216 | # 217 | # It's used internally by `policy_scope`, as well as from the views 218 | # when they call `policy_scope`. It works because views get their helper 219 | # from {Pundit::Helper}. 220 | # 221 | # @note This also memoizes the instance with `scope` as the key. 222 | # @see Pundit::Helper#policy_scope 223 | # @api private 224 | # @since v1.0.0 225 | def pundit_policy_scope(scope) 226 | policy_scopes[scope] ||= pundit.policy_scope!(scope) 227 | end 228 | private :pundit_policy_scope 229 | 230 | # @!endgroup 231 | 232 | # @!group Strong Parameters 233 | 234 | # Retrieves a set of permitted attributes from the policy. 235 | # 236 | # Done by instantiating the policy class for the given record and calling 237 | # `permitted_attributes` on it, or `permitted_attributes_for_{action}` if 238 | # `action` is defined. It then infers what key the record should have in the 239 | # params hash and retrieves the permitted attributes from the params hash 240 | # under that key. 241 | # 242 | # @see https://github.com/varvet/pundit#strong-parameters 243 | # @param record [Object] the object we're retrieving permitted attributes for 244 | # @param action [Symbol, String] the name of the action being performed on the record (e.g. `:update`). 245 | # If omitted then this defaults to the Rails controller action name. 246 | # @return [Hash{String => Object}] the permitted attributes 247 | # @since v1.0.0 248 | def permitted_attributes(record, action = action_name) 249 | policy = policy(record) 250 | method_name = if policy.respond_to?("permitted_attributes_for_#{action}") 251 | "permitted_attributes_for_#{action}" 252 | else 253 | "permitted_attributes" 254 | end 255 | pundit_params_for(record).permit(*policy.public_send(method_name)) 256 | end 257 | 258 | # Retrieves the params for the given record. 259 | # 260 | # @param record [Object] the object we're retrieving params for 261 | # @return [ActionController::Parameters] the params 262 | # @since v2.0.0 263 | def pundit_params_for(record) 264 | params.require(PolicyFinder.new(record).param_key) 265 | end 266 | 267 | # @!endgroup 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /lib/pundit/cache_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # Namespace for cache store implementations. 5 | # 6 | # Cache stores are used to cache policy lookups, so you get the same policy 7 | # instance for the same record. 8 | # @since v2.3.2 9 | module CacheStore 10 | # @!group Cache Store Interface 11 | 12 | # @!method fetch(user:, record:, &block) 13 | # Looks up a stored policy or generate a new one. 14 | # 15 | # @since v2.3.2 16 | # @note This is a method template, but the method does not exist in this module. 17 | # @param user [Object] the user that initiated the action 18 | # @param record [Object] the object being accessed 19 | # @param block [Proc] the block to execute if missing 20 | # @return [Object] the policy 21 | 22 | # @!endgroup 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/pundit/cache_store/legacy_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | module CacheStore 5 | # A cache store that uses only the record as a cache key, and ignores the user. 6 | # 7 | # The original cache mechanism used by Pundit. 8 | # 9 | # @api private 10 | # @since v2.3.2 11 | class LegacyStore 12 | # @since v2.3.2 13 | def initialize(hash = {}) 14 | @store = hash 15 | end 16 | 17 | # A cache store that uses only the record as a cache key, and ignores the user. 18 | # 19 | # @note `nil` results are not cached. 20 | # @since v2.3.2 21 | def fetch(user:, record:) 22 | _ = user 23 | @store[record] ||= yield 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/pundit/cache_store/null_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | module CacheStore 5 | # A cache store that does not cache anything. 6 | # 7 | # Use `NullStore.instance` to get the singleton instance, it is thread-safe. 8 | # 9 | # @see Pundit::Context#initialize 10 | # @api private 11 | # @since v2.3.2 12 | class NullStore 13 | @instance = new 14 | 15 | class << self 16 | # @since v2.3.2 17 | # @return [NullStore] the singleton instance 18 | attr_reader :instance 19 | end 20 | 21 | # Always yields, does not cache anything. 22 | # @yield 23 | # @return [any] whatever the block returns. 24 | # @since v2.3.2 25 | def fetch(*, **) 26 | yield 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/pundit/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # {Pundit::Context} is intended to be created once per request and user, and 5 | # it is then used to perform authorization checks throughout the request. 6 | # 7 | # @example Using Sinatra 8 | # helpers do 9 | # def current_user = ... 10 | # 11 | # def pundit 12 | # @pundit ||= Pundit::Context.new(user: current_user) 13 | # end 14 | # end 15 | # 16 | # get "/posts/:id" do |id| 17 | # pundit.authorize(Post.find(id), query: :show?) 18 | # end 19 | # 20 | # @example Using [Roda](https://roda.jeremyevans.net/index.html) 21 | # route do |r| 22 | # context = Pundit::Context.new(user:) 23 | # 24 | # r.get "posts", Integer do |id| 25 | # context.authorize(Post.find(id), query: :show?) 26 | # end 27 | # end 28 | # 29 | # @since v2.3.2 30 | class Context 31 | # @see Pundit::Authorization#pundit 32 | # @param user later passed to policies and scopes 33 | # @param policy_cache [#fetch] cache store for policies (see e.g. {CacheStore::NullStore}) 34 | # @since v2.3.2 35 | def initialize(user:, policy_cache: CacheStore::NullStore.instance) 36 | @user = user 37 | @policy_cache = policy_cache 38 | end 39 | 40 | # @api public 41 | # @see #initialize 42 | # @since v2.3.2 43 | attr_reader :user 44 | 45 | # @api private 46 | # @see #initialize 47 | # @since v2.3.2 48 | attr_reader :policy_cache 49 | 50 | # @!group Policies 51 | 52 | # Retrieves the policy for the given record, initializing it with the 53 | # record and user and finally throwing an error if the user is not 54 | # authorized to perform the given action. 55 | # 56 | # @param possibly_namespaced_record [Object, Array] the object we're checking permissions of 57 | # @param query [Symbol, String] the predicate method to check on the policy (e.g. `:show?`) 58 | # @param policy_class [Class] the policy class we want to force use of 59 | # @raise [NotAuthorizedError] if the given query method returned false 60 | # @return [Object] Always returns the passed object record 61 | # @since v2.3.2 62 | def authorize(possibly_namespaced_record, query:, policy_class:) 63 | record = pundit_model(possibly_namespaced_record) 64 | policy = if policy_class 65 | policy_class.new(user, record) 66 | else 67 | policy!(possibly_namespaced_record) 68 | end 69 | 70 | raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query) 71 | 72 | record 73 | end 74 | 75 | # Retrieves the policy for the given record. 76 | # 77 | # @see https://github.com/varvet/pundit#policies 78 | # @param record [Object] the object we're retrieving the policy for 79 | # @raise [InvalidConstructorError] if the policy constructor called incorrectly 80 | # @return [Object, nil] instance of policy class with query methods 81 | # @since v2.3.2 82 | def policy(record) 83 | cached_find(record, &:policy) 84 | end 85 | 86 | # Retrieves the policy for the given record, or raises if not found. 87 | # 88 | # @see https://github.com/varvet/pundit#policies 89 | # @param record [Object] the object we're retrieving the policy for 90 | # @raise [NotDefinedError] if the policy cannot be found 91 | # @raise [InvalidConstructorError] if the policy constructor called incorrectly 92 | # @return [Object] instance of policy class with query methods 93 | # @since v2.3.2 94 | def policy!(record) 95 | cached_find(record, &:policy!) 96 | end 97 | 98 | # @!endgroup 99 | 100 | # @!group Scopes 101 | 102 | # Retrieves the policy scope for the given record. 103 | # 104 | # @see https://github.com/varvet/pundit#scopes 105 | # @param scope [Object] the object we're retrieving the policy scope for 106 | # @raise [InvalidConstructorError] if the policy constructor called incorrectly 107 | # @return [Scope{#resolve}, nil] instance of scope class which can resolve to a scope 108 | # @since v2.3.2 109 | def policy_scope(scope) 110 | policy_scope_class = policy_finder(scope).scope 111 | return unless policy_scope_class 112 | 113 | begin 114 | policy_scope = policy_scope_class.new(user, pundit_model(scope)) 115 | rescue ArgumentError 116 | raise InvalidConstructorError, "Invalid #<#{policy_scope_class}> constructor is called" 117 | end 118 | 119 | policy_scope.resolve 120 | end 121 | 122 | # Retrieves the policy scope for the given record. Raises if not found. 123 | # 124 | # @see https://github.com/varvet/pundit#scopes 125 | # @param scope [Object] the object we're retrieving the policy scope for 126 | # @raise [NotDefinedError] if the policy scope cannot be found 127 | # @raise [InvalidConstructorError] if the policy constructor called incorrectly 128 | # @return [Scope{#resolve}] instance of scope class which can resolve to a scope 129 | # @since v2.3.2 130 | def policy_scope!(scope) 131 | policy_scope_class = policy_finder(scope).scope! 132 | 133 | begin 134 | policy_scope = policy_scope_class.new(user, pundit_model(scope)) 135 | rescue ArgumentError 136 | raise InvalidConstructorError, "Invalid #<#{policy_scope_class}> constructor is called" 137 | end 138 | 139 | policy_scope.resolve 140 | end 141 | 142 | # @!endgroup 143 | 144 | private 145 | 146 | # @!group Private Helpers 147 | 148 | # Finds a cached policy for the given record, or yields to find one. 149 | # 150 | # @api private 151 | # @param record [Object] the object we're retrieving the policy for 152 | # @yield a policy finder if no policy was cached 153 | # @yieldparam [PolicyFinder] policy_finder 154 | # @yieldreturn [#new(user, model)] 155 | # @return [Policy, nil] an instantiated policy 156 | # @raise [InvalidConstructorError] if policy can't be instantated 157 | # @since v2.3.2 158 | def cached_find(record) 159 | policy_cache.fetch(user: user, record: record) do 160 | klass = yield policy_finder(record) 161 | next unless klass 162 | 163 | model = pundit_model(record) 164 | 165 | begin 166 | klass.new(user, model) 167 | rescue ArgumentError 168 | raise InvalidConstructorError, "Invalid #<#{klass}> constructor is called" 169 | end 170 | end 171 | end 172 | 173 | # Return a policy finder for the given record. 174 | # 175 | # @api private 176 | # @return [PolicyFinder] 177 | # @since v2.3.2 178 | def policy_finder(record) 179 | PolicyFinder.new(record) 180 | end 181 | 182 | # Given a possibly namespaced record, return the actual record. 183 | # 184 | # @api private 185 | # @since v2.3.2 186 | def pundit_model(record) 187 | record.is_a?(Array) ? record.last : record 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/pundit/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # @api private 5 | # @since v1.0.0 6 | # To avoid name clashes with common Error naming when mixing in Pundit, 7 | # keep it here with compact class style definition. 8 | class Error < StandardError; end 9 | 10 | # Error that will be raised when authorization has failed 11 | # @since v0.1.0 12 | class NotAuthorizedError < Error 13 | # @see #initialize 14 | # @since v0.2.3 15 | attr_reader :query 16 | # @see #initialize 17 | # @since v0.2.3 18 | attr_reader :record 19 | # @see #initialize 20 | # @since v0.2.3 21 | attr_reader :policy 22 | 23 | # @since v1.0.0 24 | # 25 | # @overload initialize(message) 26 | # Create an error with a simple error message. 27 | # @param [String] message A simple error message string. 28 | # 29 | # @overload initialize(options) 30 | # Create an error with the specified attributes. 31 | # @param [Hash] options The error options. 32 | # @option options [String] :message Optional custom error message. Will default to a generalized message. 33 | # @option options [Symbol] :query The name of the policy method that was checked. 34 | # @option options [Object] :record The object that was being checked with the policy. 35 | # @option options [Class] :policy The class of policy that was used for the check. 36 | def initialize(options = {}) 37 | if options.is_a? String 38 | message = options 39 | else 40 | @query = options[:query] 41 | @record = options[:record] 42 | @policy = options[:policy] 43 | 44 | message = options.fetch(:message) do 45 | record_name = record.is_a?(Class) ? record.to_s : "this #{record.class}" 46 | "not allowed to #{policy.class}##{query} #{record_name}" 47 | end 48 | end 49 | 50 | super(message) 51 | end 52 | end 53 | 54 | # Error that will be raised if a policy or policy scope constructor is not called correctly. 55 | # @since v2.0.0 56 | class InvalidConstructorError < Error; end 57 | 58 | # Error that will be raised if a controller action has not called the 59 | # `authorize` or `skip_authorization` methods. 60 | # @since v0.2.3 61 | class AuthorizationNotPerformedError < Error; end 62 | 63 | # Error that will be raised if a controller action has not called the 64 | # `policy_scope` or `skip_policy_scope` methods. 65 | # @since v0.3.0 66 | class PolicyScopingNotPerformedError < AuthorizationNotPerformedError; end 67 | 68 | # Error that will be raised if a policy or policy scope is not defined. 69 | # @since v0.1.0 70 | class NotDefinedError < Error; end 71 | end 72 | -------------------------------------------------------------------------------- /lib/pundit/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # Rails view helpers, to allow a slightly different view-specific 5 | # implementation of the methods in {Pundit::Authorization}. 6 | # 7 | # @api private 8 | # @since v1.0.0 9 | module Helper 10 | # @see Pundit::Authorization#pundit_policy_scope 11 | # @since v1.0.0 12 | def policy_scope(scope) 13 | pundit_policy_scope(scope) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/pundit/policy_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # String#safe_constantize, String#demodulize, String#underscore, String#camelize 4 | require "active_support/core_ext/string/inflections" 5 | 6 | module Pundit 7 | # Finds policy and scope classes for given object. 8 | # @since v0.1.0 9 | # @api public 10 | # @example 11 | # user = User.find(params[:id]) 12 | # finder = PolicyFinder.new(user) 13 | # finder.policy #=> UserPolicy 14 | # finder.scope #=> UserPolicy::Scope 15 | # 16 | class PolicyFinder 17 | # A constant applied to the end of the class name to find the policy class. 18 | # 19 | # @api private 20 | # @since v2.5.0 21 | SUFFIX = "Policy" 22 | 23 | # @see #initialize 24 | # @since v0.1.0 25 | attr_reader :object 26 | 27 | # @param object [any] the object to find policy and scope classes for 28 | # @since v0.1.0 29 | def initialize(object) 30 | @object = object 31 | end 32 | 33 | # @return [nil, Scope{#resolve}] scope class which can resolve to a scope 34 | # @see https://github.com/varvet/pundit#scopes 35 | # @example 36 | # scope = finder.scope #=> UserPolicy::Scope 37 | # scope.resolve #=> <#ActiveRecord::Relation ...> 38 | # 39 | # @since v0.1.0 40 | def scope 41 | "#{policy}::Scope".safe_constantize 42 | end 43 | 44 | # @return [nil, Class] policy class with query methods 45 | # @see https://github.com/varvet/pundit#policies 46 | # @example 47 | # policy = finder.policy #=> UserPolicy 48 | # policy.show? #=> true 49 | # policy.update? #=> false 50 | # 51 | # @since v0.1.0 52 | def policy 53 | klass = find(object) 54 | klass.is_a?(String) ? klass.safe_constantize : klass 55 | end 56 | 57 | # @return [Scope{#resolve}] scope class which can resolve to a scope 58 | # @raise [NotDefinedError] if scope could not be determined 59 | # 60 | # @since v0.1.0 61 | def scope! 62 | scope or raise NotDefinedError, "unable to find scope `#{find(object)}::Scope` for `#{object.inspect}`" 63 | end 64 | 65 | # @return [Class] policy class with query methods 66 | # @raise [NotDefinedError] if policy could not be determined 67 | # 68 | # @since v0.1.0 69 | def policy! 70 | policy or raise NotDefinedError, "unable to find policy `#{find(object)}` for `#{object.inspect}`" 71 | end 72 | 73 | # @return [String] the name of the key this object would have in a params hash 74 | # 75 | # @since v1.1.0 76 | def param_key # rubocop:disable Metrics/AbcSize 77 | model = object.is_a?(Array) ? object.last : object 78 | 79 | if model.respond_to?(:model_name) 80 | model.model_name.param_key.to_s 81 | elsif model.is_a?(Class) 82 | model.to_s.demodulize.underscore 83 | else 84 | model.class.to_s.demodulize.underscore 85 | end 86 | end 87 | 88 | private 89 | 90 | # Given an object, find the policy class name. 91 | # 92 | # Uses recursion to handle namespaces. 93 | # 94 | # @return [String, Class] the policy class, or its name. 95 | # @since v0.2.0 96 | def find(subject) 97 | if subject.is_a?(Array) 98 | modules = subject.dup 99 | last = modules.pop 100 | context = modules.map { |x| find_class_name(x) }.join("::") 101 | [context, find(last)].join("::") 102 | elsif subject.respond_to?(:policy_class) 103 | subject.policy_class 104 | elsif subject.class.respond_to?(:policy_class) 105 | subject.class.policy_class 106 | else 107 | klass = find_class_name(subject) 108 | "#{klass}#{SUFFIX}" 109 | end 110 | end 111 | 112 | # Given an object, find its' class name. 113 | # 114 | # - Supports ActiveModel. 115 | # - Supports regular classes. 116 | # - Supports symbols. 117 | # - Supports object instances. 118 | # 119 | # @return [String, Class] the class, or its name. 120 | # @since v1.1.0 121 | def find_class_name(subject) 122 | if subject.respond_to?(:model_name) 123 | subject.model_name 124 | elsif subject.class.respond_to?(:model_name) 125 | subject.class.model_name 126 | elsif subject.is_a?(Class) 127 | subject 128 | elsif subject.is_a?(Symbol) 129 | subject.to_s.camelize 130 | else 131 | subject.class 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/pundit/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # @since v2.5.0 5 | class Railtie < Rails::Railtie 6 | if Rails.version.to_f >= 8.0 7 | initializer "pundit.stats_directories" do 8 | require "rails/code_statistics" 9 | 10 | if Rails.root.join("app/policies").directory? 11 | Rails::CodeStatistics.register_directory("Policies", "app/policies") 12 | end 13 | 14 | if Rails.root.join("test/policies").directory? 15 | Rails::CodeStatistics.register_directory("Policy tests", "test/policies", test_directory: true) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/pundit/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pundit" 4 | # Array#to_sentence 5 | require "active_support/core_ext/array/conversions" 6 | 7 | module Pundit 8 | # Namespace for Pundit's RSpec integration. 9 | # @since v0.1.0 10 | module RSpec 11 | # Namespace for Pundit's RSpec matchers. 12 | module Matchers 13 | extend ::RSpec::Matchers::DSL 14 | 15 | # @!method description=(description) 16 | class << self 17 | # Used to build a suitable description for the Pundit `permit` matcher. 18 | # @api public 19 | # @param value [String, Proc] 20 | # @example 21 | # Pundit::RSpec::Matchers.description = ->(user, record) do 22 | # "permit user with role #{user.role} to access record with ID #{record.id}" 23 | # end 24 | attr_writer :description 25 | 26 | # Used to retrieve a suitable description for the Pundit `permit` matcher. 27 | # @api private 28 | # @private 29 | def description(user, record) 30 | return @description.call(user, record) if defined?(@description) && @description.respond_to?(:call) 31 | 32 | @description 33 | end 34 | end 35 | 36 | # rubocop:disable Metrics/BlockLength 37 | matcher :permit do |user, record| 38 | match_proc = lambda do |policy| 39 | @violating_permissions = permissions.find_all do |permission| 40 | !policy.new(user, record).public_send(permission) 41 | end 42 | @violating_permissions.empty? 43 | end 44 | 45 | match_when_negated_proc = lambda do |policy| 46 | @violating_permissions = permissions.find_all do |permission| 47 | policy.new(user, record).public_send(permission) 48 | end 49 | @violating_permissions.empty? 50 | end 51 | 52 | failure_message_proc = lambda do |policy| 53 | "Expected #{policy} to grant #{permissions.to_sentence} on " \ 54 | "#{record} but #{@violating_permissions.to_sentence} #{was_or_were} not granted" 55 | end 56 | 57 | failure_message_when_negated_proc = lambda do |policy| 58 | "Expected #{policy} not to grant #{permissions.to_sentence} on " \ 59 | "#{record} but #{@violating_permissions.to_sentence} #{was_or_were} granted" 60 | end 61 | 62 | def was_or_were 63 | if @violating_permissions.count > 1 64 | "were" 65 | else 66 | "was" 67 | end 68 | end 69 | 70 | description do 71 | Pundit::RSpec::Matchers.description(user, record) || super() 72 | end 73 | 74 | if respond_to?(:match_when_negated) 75 | match(&match_proc) 76 | match_when_negated(&match_when_negated_proc) 77 | failure_message(&failure_message_proc) 78 | failure_message_when_negated(&failure_message_when_negated_proc) 79 | else 80 | # :nocov: 81 | # Compatibility with RSpec < 3.0, released 2014-06-01. 82 | match_for_should(&match_proc) 83 | match_for_should_not(&match_when_negated_proc) 84 | failure_message_for_should(&failure_message_proc) 85 | failure_message_for_should_not(&failure_message_when_negated_proc) 86 | # :nocov: 87 | end 88 | 89 | if ::RSpec.respond_to?(:current_example) 90 | def current_example 91 | ::RSpec.current_example 92 | end 93 | else 94 | # :nocov: 95 | # Compatibility with RSpec < 3.0, released 2014-06-01. 96 | def current_example 97 | example 98 | end 99 | # :nocov: 100 | end 101 | 102 | def permissions 103 | current_example.metadata.fetch(:permissions) do 104 | raise KeyError, <<~ERROR.strip 105 | No permissions in example metadata, did you forget to wrap with `permissions :show?, ...`? 106 | ERROR 107 | end 108 | end 109 | end 110 | # rubocop:enable Metrics/BlockLength 111 | end 112 | 113 | # Mixed in to all policy example groups to provide a DSL. 114 | module DSL 115 | # @example 116 | # describe PostPolicy do 117 | # permissions :show?, :update? do 118 | # it { is_expected.to permit(user, own_post) } 119 | # end 120 | # end 121 | # 122 | # @example focused example group 123 | # describe PostPolicy do 124 | # permissions :show?, :update?, :focus do 125 | # it { is_expected.to permit(user, own_post) } 126 | # end 127 | # end 128 | # 129 | # @param list [Symbol, Array] a permission to describe 130 | # @return [void] 131 | def permissions(*list, &block) 132 | metadata = { permissions: list, caller: caller } 133 | 134 | if list.last == :focus 135 | list.pop 136 | metadata[:focus] = true 137 | end 138 | 139 | description = list.to_sentence 140 | describe(description, metadata) { instance_eval(&block) } 141 | end 142 | end 143 | 144 | # Mixed in to all policy example groups. 145 | # 146 | # @private not useful 147 | module PolicyExampleGroup 148 | include Pundit::RSpec::Matchers 149 | 150 | def self.included(base) 151 | base.metadata[:type] = :policy 152 | base.extend Pundit::RSpec::DSL 153 | super 154 | end 155 | end 156 | end 157 | end 158 | 159 | RSpec.configure do |config| 160 | config.include( 161 | Pundit::RSpec::PolicyExampleGroup, 162 | type: :policy, 163 | file_path: %r{spec/policies} 164 | ) 165 | end 166 | -------------------------------------------------------------------------------- /lib/pundit/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pundit 4 | # The current version of Pundit. 5 | VERSION = "2.5.0" 6 | end 7 | -------------------------------------------------------------------------------- /pundit.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "pundit/version" 6 | 7 | Gem::Specification.new do |gem| 8 | gem.name = "pundit" 9 | gem.version = Pundit::VERSION 10 | gem.authors = ["Jonas Nicklas", "Varvet AB"] 11 | gem.email = ["jonas.nicklas@gmail.com", "info@varvet.com"] 12 | gem.description = "Object oriented authorization for Rails applications" 13 | gem.summary = "OO authorization for Rails" 14 | gem.homepage = "https://github.com/varvet/pundit" 15 | gem.license = "MIT" 16 | 17 | Dir.chdir(__dir__) do 18 | gem.files = `git ls-files -z`.split("\x0").select do |f| 19 | f.start_with?("lib/", "README", "SECURITY", "LICENSE", "CHANGELOG") 20 | end 21 | end 22 | gem.require_paths = ["lib"] 23 | 24 | gem.metadata = { 25 | "rubygems_mfa_required" => "true", 26 | "bug_tracker_uri" => "https://github.com/varvet/pundit/issues", 27 | "changelog_uri" => "https://github.com/varvet/pundit/blob/main/CHANGELOG.md", 28 | "documentation_uri" => "https://github.com/varvet/pundit/blob/main/README.md", 29 | "homepage_uri" => "https://github.com/varvet/pundit", 30 | "source_code_uri" => "https://github.com/varvet/pundit" 31 | } 32 | 33 | gem.add_dependency "activesupport", ">= 3.0.0" 34 | end 35 | -------------------------------------------------------------------------------- /spec/authorization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "action_controller/metal/strong_parameters" 5 | 6 | describe Pundit::Authorization do 7 | def to_params(*args, **kwargs, &block) 8 | ActionController::Parameters.new(*args, **kwargs, &block) 9 | end 10 | 11 | let(:controller) { Controller.new(user, "update", to_params({})) } 12 | let(:user) { double("user") } 13 | let(:post) { Post.new(user) } 14 | let(:comment) { Comment.new } 15 | let(:article) { Article.new } 16 | let(:article_tag) { ArticleTag.new } 17 | let(:wiki) { Wiki.new } 18 | 19 | describe "#verify_authorized" do 20 | it "does nothing when authorized" do 21 | controller.authorize(post) 22 | controller.verify_authorized 23 | end 24 | 25 | it "raises an exception when not authorized" do 26 | expect { controller.verify_authorized }.to raise_error(Pundit::AuthorizationNotPerformedError) 27 | end 28 | end 29 | 30 | describe "#verify_policy_scoped" do 31 | it "does nothing when policy_scope is used" do 32 | controller.policy_scope(Post) 33 | controller.verify_policy_scoped 34 | end 35 | 36 | it "raises an exception when policy_scope is not used" do 37 | expect { controller.verify_policy_scoped }.to raise_error(Pundit::PolicyScopingNotPerformedError) 38 | end 39 | end 40 | 41 | describe "#pundit_policy_authorized?" do 42 | it "is true when authorized" do 43 | controller.authorize(post) 44 | expect(controller.pundit_policy_authorized?).to be true 45 | end 46 | 47 | it "is false when not authorized" do 48 | expect(controller.pundit_policy_authorized?).to be false 49 | end 50 | end 51 | 52 | describe "#pundit_policy_scoped?" do 53 | it "is true when policy_scope is used" do 54 | controller.policy_scope(Post) 55 | expect(controller.pundit_policy_scoped?).to be true 56 | end 57 | 58 | it "is false when policy scope is not used" do 59 | expect(controller.pundit_policy_scoped?).to be false 60 | end 61 | end 62 | 63 | describe "#authorize" do 64 | it "infers the policy name and authorizes based on it" do 65 | expect(controller.authorize(post)).to be_truthy 66 | end 67 | 68 | it "returns the record on successful authorization" do 69 | expect(controller.authorize(post)).to eq(post) 70 | end 71 | 72 | it "returns the record when passed record with namespace " do 73 | expect(controller.authorize([:project, comment], :update?)).to eq(comment) 74 | end 75 | 76 | it "returns the record when passed record with nested namespace " do 77 | expect(controller.authorize([:project, :admin, comment], :update?)).to eq(comment) 78 | end 79 | 80 | it "returns the policy name symbol when passed record with headless policy" do 81 | expect(controller.authorize(:publication, :create?)).to eq(:publication) 82 | end 83 | 84 | it "returns the class when passed record not a particular instance" do 85 | expect(controller.authorize(Post, :show?)).to eq(Post) 86 | end 87 | 88 | it "can be given a different permission to check" do 89 | expect(controller.authorize(post, :show?)).to be_truthy 90 | expect { controller.authorize(post, :destroy?) }.to raise_error(Pundit::NotAuthorizedError) 91 | end 92 | 93 | it "can be given a different policy class" do 94 | expect(controller.authorize(post, :create?, policy_class: PublicationPolicy)).to be_truthy 95 | end 96 | 97 | it "works with anonymous class policies" do 98 | expect(controller.authorize(article_tag, :show?)).to be_truthy 99 | expect { controller.authorize(article_tag, :destroy?) }.to raise_error(Pundit::NotAuthorizedError) 100 | end 101 | 102 | it "throws an exception when the permission check fails" do 103 | expect { controller.authorize(Post.new) }.to raise_error(Pundit::NotAuthorizedError) 104 | end 105 | 106 | it "throws an exception when a policy cannot be found" do 107 | expect { controller.authorize(Article) }.to raise_error(Pundit::NotDefinedError) 108 | end 109 | 110 | it "caches the policy" do 111 | expect(controller.policies[post]).to be_nil 112 | controller.authorize(post) 113 | expect(controller.policies[post]).not_to be_nil 114 | end 115 | 116 | it "raises an error when the given record is nil" do 117 | expect { controller.authorize(nil, :destroy?) }.to raise_error(Pundit::NotAuthorizedError) 118 | end 119 | 120 | it "raises an error with a invalid policy constructor" do 121 | expect { controller.authorize(wiki, :destroy?) }.to raise_error(Pundit::InvalidConstructorError) 122 | end 123 | end 124 | 125 | describe "#skip_authorization" do 126 | it "disables authorization verification" do 127 | controller.skip_authorization 128 | expect { controller.verify_authorized }.not_to raise_error 129 | end 130 | end 131 | 132 | describe "#skip_policy_scope" do 133 | it "disables policy scope verification" do 134 | controller.skip_policy_scope 135 | expect { controller.verify_policy_scoped }.not_to raise_error 136 | end 137 | end 138 | 139 | describe "#pundit_user" do 140 | it "returns the same thing as current_user" do 141 | expect(controller.pundit_user).to eq controller.current_user 142 | end 143 | end 144 | 145 | describe "#policy" do 146 | it "returns an instantiated policy" do 147 | policy = controller.policy(post) 148 | expect(policy.user).to eq user 149 | expect(policy.post).to eq post 150 | end 151 | 152 | it "throws an exception if the given policy can't be found" do 153 | expect { controller.policy(article) }.to raise_error(Pundit::NotDefinedError) 154 | end 155 | 156 | it "raises an error with a invalid policy constructor" do 157 | expect { controller.policy(wiki) }.to raise_error(Pundit::InvalidConstructorError) 158 | end 159 | 160 | it "allows policy to be injected" do 161 | new_policy = double 162 | controller.policies[post] = new_policy 163 | 164 | expect(controller.policy(post)).to eq new_policy 165 | end 166 | end 167 | 168 | describe "#policy_scope" do 169 | it "returns an instantiated policy scope" do 170 | expect(controller.policy_scope(Post)).to eq :published 171 | end 172 | 173 | it "allows policy scope class to be overridden" do 174 | expect(controller.policy_scope(Post, policy_scope_class: PublicationPolicy::Scope)).to eq :published 175 | end 176 | 177 | it "throws an exception if the given policy can't be found" do 178 | expect { controller.policy_scope(Article) }.to raise_error(Pundit::NotDefinedError) 179 | end 180 | 181 | it "raises an error with a invalid policy scope constructor" do 182 | expect { controller.policy_scope(Wiki) }.to raise_error(Pundit::InvalidConstructorError) 183 | end 184 | 185 | it "allows policy_scope to be injected" do 186 | new_scope = double 187 | controller.policy_scopes[Post] = new_scope 188 | 189 | expect(controller.policy_scope(Post)).to eq new_scope 190 | end 191 | end 192 | 193 | describe "#permitted_attributes" do 194 | it "checks policy for permitted attributes" do 195 | params = to_params( 196 | post: { 197 | title: "Hello", 198 | votes: 5, 199 | admin: true 200 | } 201 | ) 202 | 203 | action = "update" 204 | 205 | expect(Controller.new(user, action, params).permitted_attributes(post).to_h).to eq( 206 | "title" => "Hello", 207 | "votes" => 5 208 | ) 209 | expect(Controller.new(double, action, params).permitted_attributes(post).to_h).to eq("votes" => 5) 210 | end 211 | 212 | it "checks policy for permitted attributes for record of a ActiveModel type" do 213 | customer_post = Customer::Post.new(user) 214 | params = to_params( 215 | customer_post: { 216 | title: "Hello", 217 | votes: 5, 218 | admin: true 219 | } 220 | ) 221 | 222 | action = "update" 223 | 224 | expect(Controller.new(user, action, params).permitted_attributes(customer_post).to_h).to eq( 225 | "title" => "Hello", 226 | "votes" => 5 227 | ) 228 | expect(Controller.new(double, action, params).permitted_attributes(customer_post).to_h).to eq( 229 | "votes" => 5 230 | ) 231 | end 232 | 233 | it "goes through the policy cache" do 234 | params = to_params(post: { title: "Hello" }) 235 | user = double 236 | post = Post.new(user) 237 | controller = Controller.new(user, "update", params) 238 | 239 | expect do 240 | expect(controller.permitted_attributes(post)).to be_truthy 241 | expect(controller.permitted_attributes(post)).to be_truthy 242 | end.to change { PostPolicy.instances }.by(1) 243 | end 244 | end 245 | 246 | describe "#permitted_attributes_for_action" do 247 | it "is checked if it is defined in the policy" do 248 | params = to_params( 249 | post: { 250 | title: "Hello", 251 | body: "blah", 252 | votes: 5, 253 | admin: true 254 | } 255 | ) 256 | 257 | action = "revise" 258 | expect(Controller.new(user, action, params).permitted_attributes(post).to_h).to eq("body" => "blah") 259 | end 260 | 261 | it "can be explicitly set" do 262 | params = to_params( 263 | post: { 264 | title: "Hello", 265 | body: "blah", 266 | votes: 5, 267 | admin: true 268 | } 269 | ) 270 | 271 | action = "update" 272 | expect(Controller.new(user, action, params).permitted_attributes(post, :revise).to_h).to eq("body" => "blah") 273 | end 274 | end 275 | 276 | describe "#pundit_reset!" do 277 | it "allows authorize to react to a user change" do 278 | expect(controller.authorize(post)).to be_truthy 279 | 280 | controller.current_user = double 281 | controller.pundit_reset! 282 | expect { controller.authorize(post) }.to raise_error(Pundit::NotAuthorizedError) 283 | end 284 | 285 | it "allows policy to react to a user change" do 286 | expect(controller.policy(DummyCurrentUser).user).to be user 287 | 288 | new_user = double("new user") 289 | controller.current_user = new_user 290 | controller.pundit_reset! 291 | expect(controller.policy(DummyCurrentUser).user).to be new_user 292 | end 293 | 294 | it "allows policy scope to react to a user change" do 295 | expect(controller.policy_scope(DummyCurrentUser)).to be user 296 | 297 | new_user = double("new user") 298 | controller.current_user = new_user 299 | controller.pundit_reset! 300 | expect(controller.policy_scope(DummyCurrentUser)).to be new_user 301 | end 302 | 303 | it "resets the pundit context" do 304 | expect(controller.pundit.user).to be(user) 305 | 306 | new_user = double 307 | controller.current_user = new_user 308 | expect { controller.pundit_reset! }.to change { controller.pundit.user }.from(user).to(new_user) 309 | end 310 | 311 | it "clears pundit_policy_authorized? flag" do 312 | expect(controller.pundit_policy_authorized?).to be false 313 | 314 | controller.skip_authorization 315 | expect(controller.pundit_policy_authorized?).to be true 316 | 317 | controller.pundit_reset! 318 | expect(controller.pundit_policy_authorized?).to be false 319 | end 320 | 321 | it "clears pundit_policy_scoped? flag" do 322 | expect(controller.pundit_policy_scoped?).to be false 323 | 324 | controller.skip_policy_scope 325 | expect(controller.pundit_policy_scoped?).to be true 326 | 327 | controller.pundit_reset! 328 | expect(controller.pundit_policy_scoped?).to be false 329 | end 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /spec/generators_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "tmpdir" 5 | 6 | require "rails/generators" 7 | require "generators/pundit/install/install_generator" 8 | require "generators/pundit/policy/policy_generator" 9 | 10 | RSpec.describe "generators" do 11 | before(:all) do 12 | @tmpdir = Dir.mktmpdir 13 | 14 | Dir.chdir(@tmpdir) do 15 | Pundit::Generators::InstallGenerator.new([], { quiet: true }).invoke_all 16 | Pundit::Generators::PolicyGenerator.new(%w[Widget], { quiet: true }).invoke_all 17 | 18 | require "./app/policies/application_policy" 19 | require "./app/policies/widget_policy" 20 | end 21 | end 22 | 23 | after(:all) do 24 | FileUtils.remove_entry(@tmpdir) 25 | end 26 | 27 | describe "WidgetPolicy", type: :policy do 28 | permissions :index?, :show?, :create?, :new?, :update?, :edit?, :destroy? do 29 | it "has safe defaults" do 30 | expect(WidgetPolicy).not_to permit(double("User"), double("Widget")) 31 | end 32 | end 33 | 34 | describe "WidgetPolicy::Scope" do 35 | describe "#resolve" do 36 | it "raises a descriptive error" do 37 | scope = WidgetPolicy::Scope.new(double("User"), double("User.all")) 38 | expect { scope.resolve }.to raise_error(NoMethodError, /WidgetPolicy::Scope/) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/policies/post_policy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PostPolicy do 6 | let(:user) { double } 7 | let(:own_post) { double(user: user) } 8 | let(:other_post) { double(user: double) } 9 | subject { described_class } 10 | 11 | permissions :update?, :show? do 12 | it "is successful when all permissions match" do 13 | should permit(user, own_post) 14 | end 15 | 16 | it "fails when any permissions do not match" do 17 | expect do 18 | should permit(user, other_post) 19 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 20 | end 21 | 22 | it "uses the default description if not overridden" do 23 | expect(permit(user, own_post).description).to eq("permit #{user.inspect} and #{own_post.inspect}") 24 | end 25 | 26 | context "when the matcher description is overridden" do 27 | after do 28 | Pundit::RSpec::Matchers.description = nil 29 | end 30 | 31 | it "sets a custom matcher description with a Proc" do 32 | allow(user).to receive(:role).and_return("default_role") 33 | allow(own_post).to receive(:id).and_return(1) 34 | 35 | Pundit::RSpec::Matchers.description = lambda { |user, record| 36 | "permit user with role #{user.role} to access record with ID #{record.id}" 37 | } 38 | 39 | description = permit(user, own_post).description 40 | expect(description).to eq("permit user with role default_role to access record with ID 1") 41 | end 42 | 43 | it "sets a custom matcher description with a string" do 44 | Pundit::RSpec::Matchers.description = "permit user" 45 | expect(permit(user, own_post).description).to eq("permit user") 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/policy_finder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Pundit::PolicyFinder do 6 | let(:user) { double } 7 | let(:post) { Post.new(user) } 8 | let(:comment) { CommentFourFiveSix.new } 9 | let(:article) { Article.new } 10 | 11 | describe "SUFFIX" do 12 | specify { expect(described_class::SUFFIX).to eq "Policy" } 13 | specify { expect(Pundit::SUFFIX).to eq(described_class::SUFFIX) } 14 | end 15 | 16 | describe "#scope" do 17 | subject { described_class.new(post) } 18 | 19 | it "returns a policy scope" do 20 | expect(subject.scope).to eq PostPolicy::Scope 21 | end 22 | 23 | context "policy is nil" do 24 | it "returns nil" do 25 | allow(subject).to receive(:policy).and_return nil 26 | expect(subject.scope).to eq nil 27 | end 28 | end 29 | end 30 | 31 | describe "#policy" do 32 | context "with an instance" do 33 | it "returns the associated policy" do 34 | object = described_class.new(post) 35 | 36 | expect(object.policy).to eq PostPolicy 37 | end 38 | end 39 | 40 | context "with an array of symbols" do 41 | it "returns the associated namespaced policy" do 42 | object = described_class.new(%i[project post]) 43 | 44 | expect(object.policy).to eq Project::PostPolicy 45 | end 46 | end 47 | 48 | context "with an array of a symbol and an instance" do 49 | it "returns the associated namespaced policy" do 50 | object = described_class.new([:project, post]) 51 | 52 | expect(object.policy).to eq Project::PostPolicy 53 | end 54 | end 55 | 56 | context "with an array of a symbol and a class with a specified policy class" do 57 | it "returns the associated namespaced policy" do 58 | object = described_class.new([:project, Customer::Post]) 59 | 60 | expect(object.policy).to eq Project::PostPolicy 61 | end 62 | end 63 | 64 | context "with an array of a symbol and a class with a specified model name" do 65 | it "returns the associated namespaced policy" do 66 | object = described_class.new([:project, CommentsRelation]) 67 | 68 | expect(object.policy).to eq Project::CommentPolicy 69 | end 70 | end 71 | 72 | context "with a class" do 73 | it "returns the associated policy" do 74 | object = described_class.new(Post) 75 | 76 | expect(object.policy).to eq PostPolicy 77 | end 78 | end 79 | 80 | context "with a class which has a specified policy class" do 81 | it "returns the associated policy" do 82 | object = described_class.new(Customer::Post) 83 | 84 | expect(object.policy).to eq PostPolicy 85 | end 86 | end 87 | 88 | context "with an instance which has a specified policy class" do 89 | it "returns the associated policy" do 90 | object = described_class.new(Customer::Post.new(user)) 91 | 92 | expect(object.policy).to eq PostPolicy 93 | end 94 | end 95 | 96 | context "with a class which has a specified model name" do 97 | it "returns the associated policy" do 98 | object = described_class.new(CommentsRelation) 99 | 100 | expect(object.policy).to eq CommentPolicy 101 | end 102 | end 103 | 104 | context "with an instance which has a specified policy class" do 105 | it "returns the associated policy" do 106 | object = described_class.new(CommentsRelation.new) 107 | 108 | expect(object.policy).to eq CommentPolicy 109 | end 110 | end 111 | 112 | context "with nil" do 113 | it "returns a NilClassPolicy" do 114 | object = described_class.new(nil) 115 | 116 | expect(object.policy).to eq NilClassPolicy 117 | end 118 | end 119 | 120 | context "with a class that doesn't have an associated policy" do 121 | it "returns nil" do 122 | object = described_class.new(Foo) 123 | 124 | expect(object.policy).to eq nil 125 | end 126 | end 127 | end 128 | 129 | describe "#scope!" do 130 | context "@object is nil" do 131 | subject { described_class.new(nil) } 132 | 133 | it "returns the NilClass policy's scope class" do 134 | expect(subject.scope!).to eq NilClassPolicy::Scope 135 | end 136 | end 137 | 138 | context "@object is defined" do 139 | subject { described_class.new(post) } 140 | 141 | it "returns the scope" do 142 | expect(subject.scope!).to eq PostPolicy::Scope 143 | end 144 | end 145 | end 146 | 147 | describe "#param_key" do 148 | context "object responds to model_name" do 149 | subject { described_class.new(comment) } 150 | 151 | it "returns the param_key" do 152 | expect(subject.object).to respond_to(:model_name) 153 | expect(subject.param_key).to eq "comment_four_five_six" 154 | end 155 | end 156 | 157 | context "object is a class" do 158 | subject { described_class.new(Article) } 159 | 160 | it "returns the param_key" do 161 | expect(subject.object).not_to respond_to(:model_name) 162 | expect(subject.object).to be_a Class 163 | expect(subject.param_key).to eq "article" 164 | end 165 | end 166 | 167 | context "object is an instance of a class" do 168 | subject { described_class.new(article) } 169 | 170 | it "returns the param_key" do 171 | expect(subject.object).not_to respond_to(:model_name) 172 | expect(subject.object).not_to be_a Class 173 | expect(subject.object).to be_an_instance_of Article 174 | 175 | expect(subject.param_key).to eq "article" 176 | end 177 | end 178 | 179 | context "object is an array" do 180 | subject { described_class.new([:project, article]) } 181 | 182 | it "returns the param_key for the last element of the array" do 183 | expect(subject.object).not_to respond_to(:model_name) 184 | expect(subject.object).not_to be_a Class 185 | expect(subject.object).to be_an_instance_of Array 186 | 187 | expect(subject.param_key).to eq "article" 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /spec/pundit/helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Pundit::Helper do 6 | let(:user) { double } 7 | let(:controller) { Controller.new(user, "update", double) } 8 | let(:view) { Controller::View.new(controller) } 9 | 10 | describe "#policy_scope" do 11 | it "doesn't flip pundit_policy_scoped?" do 12 | scoped = view.policy_scope(Post) 13 | 14 | expect(scoped).to be(Post.published) 15 | expect(controller).not_to be_pundit_policy_scoped 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/pundit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Pundit do 6 | let(:user) { double } 7 | let(:post) { Post.new(user) } 8 | let(:customer_post) { Customer::Post.new(user) } 9 | let(:post_four_five_six) { PostFourFiveSix.new(user) } 10 | let(:comment) { Comment.new } 11 | let(:comment_four_five_six) { CommentFourFiveSix.new } 12 | let(:article) { Article.new } 13 | let(:artificial_blog) { ArtificialBlog.new } 14 | let(:article_tag) { ArticleTag.new } 15 | let(:comments_relation) { CommentsRelation.new(empty: false) } 16 | let(:empty_comments_relation) { CommentsRelation.new(empty: true) } 17 | let(:tag_four_five_six) { ProjectOneTwoThree::TagFourFiveSix.new(user) } 18 | let(:avatar_four_five_six) { ProjectOneTwoThree::AvatarFourFiveSix.new } 19 | let(:wiki) { Wiki.new } 20 | 21 | describe ".authorize" do 22 | it "infers the policy and authorizes based on it" do 23 | expect(Pundit.authorize(user, post, :update?)).to be_truthy 24 | end 25 | 26 | it "returns the record on successful authorization" do 27 | expect(Pundit.authorize(user, post, :update?)).to eq(post) 28 | end 29 | 30 | it "returns the record when passed record with namespace " do 31 | expect(Pundit.authorize(user, [:project, comment], :update?)).to eq(comment) 32 | end 33 | 34 | it "returns the record when passed record with nested namespace " do 35 | expect(Pundit.authorize(user, [:project, :admin, comment], :update?)).to eq(comment) 36 | end 37 | 38 | it "returns the policy name symbol when passed record with headless policy" do 39 | expect(Pundit.authorize(user, :publication, :create?)).to eq(:publication) 40 | end 41 | 42 | it "returns the class when passed record not a particular instance" do 43 | expect(Pundit.authorize(user, Post, :show?)).to eq(Post) 44 | end 45 | 46 | it "works with anonymous class policies" do 47 | expect(Pundit.authorize(user, article_tag, :show?)).to be_truthy 48 | expect { Pundit.authorize(user, article_tag, :destroy?) }.to raise_error(Pundit::NotAuthorizedError) 49 | end 50 | 51 | it "raises an error with the policy, query and record" do 52 | # rubocop:disable Style/MultilineBlockChain 53 | expect do 54 | Pundit.authorize(user, post, :destroy?) 55 | end.to raise_error(Pundit::NotAuthorizedError, "not allowed to PostPolicy#destroy? this Post") do |error| 56 | expect(error.query).to eq :destroy? 57 | expect(error.record).to eq post 58 | expect(error.policy).to have_attributes( 59 | user: user, 60 | record: post 61 | ) 62 | expect(error.policy).to be_a(PostPolicy) 63 | end 64 | # rubocop:enable Style/MultilineBlockChain 65 | end 66 | 67 | it "raises an error with the policy, query and record when the record is namespaced" do 68 | # rubocop:disable Style/MultilineBlockChain 69 | expect do 70 | Pundit.authorize(user, [:project, :admin, comment], :destroy?) 71 | end.to raise_error(Pundit::NotAuthorizedError, 72 | "not allowed to Project::Admin::CommentPolicy#destroy? this Comment") do |error| 73 | expect(error.query).to eq :destroy? 74 | expect(error.record).to eq comment 75 | expect(error.policy).to have_attributes( 76 | user: user, 77 | record: comment 78 | ) 79 | expect(error.policy).to be_a(Project::Admin::CommentPolicy) 80 | end 81 | # rubocop:enable Style/MultilineBlockChain 82 | end 83 | 84 | it "raises an error with the policy, query and the class name when a Class is given" do 85 | # rubocop:disable Style/MultilineBlockChain 86 | expect do 87 | Pundit.authorize(user, Post, :destroy?) 88 | end.to raise_error(Pundit::NotAuthorizedError, "not allowed to PostPolicy#destroy? Post") do |error| 89 | expect(error.query).to eq :destroy? 90 | expect(error.record).to eq Post 91 | expect(error.policy).to have_attributes( 92 | user: user, 93 | record: Post 94 | ) 95 | expect(error.policy).to be_a(PostPolicy) 96 | end 97 | # rubocop:enable Style/MultilineBlockChain 98 | end 99 | 100 | it "raises an error with a invalid policy constructor" do 101 | expect do 102 | Pundit.authorize(user, wiki, :update?) 103 | end.to raise_error(Pundit::InvalidConstructorError, "Invalid # constructor is called") 104 | end 105 | 106 | context "when passed a policy class" do 107 | it "uses the passed policy class" do 108 | expect(Pundit.authorize(user, post, :create?, policy_class: PublicationPolicy)).to be_truthy 109 | end 110 | 111 | # This is documenting past behaviour. 112 | it "doesn't cache the policy class" do 113 | cache = {} 114 | 115 | expect do 116 | Pundit.authorize(user, post, :create?, policy_class: PublicationPolicy, cache: cache) 117 | Pundit.authorize(user, post, :create?, policy_class: PublicationPolicy, cache: cache) 118 | end.to change { PublicationPolicy.instances }.by(2) 119 | end 120 | end 121 | 122 | context "when passed a policy class while simultaenously passing a namespace" do 123 | it "uses the passed policy class" do 124 | expect(PublicationPolicy).to receive(:new).with(user, comment).and_call_original 125 | expect(Pundit.authorize(user, [:project, comment], :create?, policy_class: PublicationPolicy)).to be_truthy 126 | end 127 | end 128 | 129 | context "when passed an explicit cache" do 130 | it "uses the hash assignment interface on the cache" do 131 | custom_cache = CustomCache.new 132 | 133 | Pundit.authorize(user, post, :update?, cache: custom_cache) 134 | 135 | expect(custom_cache.to_h).to match({ 136 | post => kind_of(PostPolicy) 137 | }) 138 | end 139 | end 140 | end 141 | 142 | describe ".policy_scope" do 143 | it "returns an instantiated policy scope given a plain model class" do 144 | expect(Pundit.policy_scope(user, Post)).to eq :published 145 | end 146 | 147 | it "returns an instantiated policy scope given an active model class" do 148 | expect(Pundit.policy_scope(user, Comment)).to eq CommentScope.new(Comment) 149 | end 150 | 151 | it "returns an instantiated policy scope given an active record relation" do 152 | expect(Pundit.policy_scope(user, comments_relation)).to eq CommentScope.new(comments_relation) 153 | end 154 | 155 | it "returns an instantiated policy scope given an empty active record relation" do 156 | expect(Pundit.policy_scope(user, empty_comments_relation)).to eq CommentScope.new(empty_comments_relation) 157 | end 158 | 159 | it "returns an instantiated policy scope given an array of a symbol and plain model class" do 160 | expect(Pundit.policy_scope(user, [:project, Post])).to eq :read 161 | end 162 | 163 | it "returns an instantiated policy scope given an array of a symbol and active model class" do 164 | expect(Pundit.policy_scope(user, [:project, Comment])).to eq Comment 165 | end 166 | 167 | it "returns nil if the given policy scope can't be found" do 168 | expect(Pundit.policy_scope(user, Article)).to be_nil 169 | end 170 | 171 | it "raises an exception if nil object given" do 172 | expect { Pundit.policy_scope(user, nil) }.to raise_error(Pundit::NotDefinedError) 173 | end 174 | 175 | it "raises an error with a invalid policy scope constructor" do 176 | expect do 177 | Pundit.policy_scope(user, Wiki) 178 | end.to raise_error(Pundit::InvalidConstructorError, "Invalid # constructor is called") 179 | end 180 | 181 | it "raises an original error with a policy scope that contains error" do 182 | expect do 183 | Pundit.policy_scope(user, DefaultScopeContainsError) 184 | end.to raise_error(RuntimeError, "This is an arbitrary error that should bubble up") 185 | end 186 | end 187 | 188 | describe ".policy_scope!" do 189 | it "returns an instantiated policy scope given a plain model class" do 190 | expect(Pundit.policy_scope!(user, Post)).to eq :published 191 | end 192 | 193 | it "returns an instantiated policy scope given an active model class" do 194 | expect(Pundit.policy_scope!(user, Comment)).to eq CommentScope.new(Comment) 195 | end 196 | 197 | it "throws an exception if the given policy scope can't be found" do 198 | expect { Pundit.policy_scope!(user, Article) }.to raise_error(Pundit::NotDefinedError) 199 | end 200 | 201 | it "throws an exception if the given policy scope can't be found" do 202 | expect { Pundit.policy_scope!(user, ArticleTag) }.to raise_error(Pundit::NotDefinedError) 203 | end 204 | 205 | it "throws an exception if the given policy scope is nil" do 206 | expect do 207 | Pundit.policy_scope!(user, nil) 208 | end.to raise_error(Pundit::NotDefinedError, "Cannot scope NilClass") 209 | end 210 | 211 | it "returns an instantiated policy scope given an array of a symbol and plain model class" do 212 | expect(Pundit.policy_scope!(user, [:project, Post])).to eq :read 213 | end 214 | 215 | it "returns an instantiated policy scope given an array of a symbol and active model class" do 216 | expect(Pundit.policy_scope!(user, [:project, Comment])).to eq Comment 217 | end 218 | 219 | it "raises an error with a invalid policy scope constructor" do 220 | expect do 221 | Pundit.policy_scope(user, Wiki) 222 | end.to raise_error(Pundit::InvalidConstructorError, "Invalid # constructor is called") 223 | end 224 | end 225 | 226 | describe ".policy" do 227 | it "returns an instantiated policy given a plain model instance" do 228 | policy = Pundit.policy(user, post) 229 | expect(policy.user).to eq user 230 | expect(policy.post).to eq post 231 | end 232 | 233 | it "returns an instantiated policy given an active model instance" do 234 | policy = Pundit.policy(user, comment) 235 | expect(policy.user).to eq user 236 | expect(policy.comment).to eq comment 237 | end 238 | 239 | it "returns an instantiated policy given a plain model class" do 240 | policy = Pundit.policy(user, Post) 241 | expect(policy.user).to eq user 242 | expect(policy.post).to eq Post 243 | end 244 | 245 | it "returns an instantiated policy given an active model class" do 246 | policy = Pundit.policy(user, Comment) 247 | expect(policy.user).to eq user 248 | expect(policy.comment).to eq Comment 249 | end 250 | 251 | it "returns an instantiated policy given a symbol" do 252 | policy = Pundit.policy(user, :criteria) 253 | expect(policy.class).to eq CriteriaPolicy 254 | expect(policy.user).to eq user 255 | expect(policy.criteria).to eq :criteria 256 | end 257 | 258 | it "returns an instantiated policy given an array of symbols" do 259 | policy = Pundit.policy(user, %i[project criteria]) 260 | expect(policy.class).to eq Project::CriteriaPolicy 261 | expect(policy.user).to eq user 262 | expect(policy.criteria).to eq :criteria 263 | end 264 | 265 | it "returns an instantiated policy given an array of a symbol and plain model instance" do 266 | policy = Pundit.policy(user, [:project, post]) 267 | expect(policy.class).to eq Project::PostPolicy 268 | expect(policy.user).to eq user 269 | expect(policy.post).to eq post 270 | end 271 | 272 | it "returns an instantiated policy given an array of a symbol and a model instance with policy_class override" do 273 | policy = Pundit.policy(user, [:project, customer_post]) 274 | expect(policy.class).to eq Project::PostPolicy 275 | expect(policy.user).to eq user 276 | expect(policy.post).to eq customer_post 277 | end 278 | 279 | it "returns an instantiated policy given an array of a symbol and an active model instance" do 280 | policy = Pundit.policy(user, [:project, comment]) 281 | expect(policy.class).to eq Project::CommentPolicy 282 | expect(policy.user).to eq user 283 | expect(policy.comment).to eq comment 284 | end 285 | 286 | it "returns an instantiated policy given an array of a symbol and a plain model class" do 287 | policy = Pundit.policy(user, [:project, Post]) 288 | expect(policy.class).to eq Project::PostPolicy 289 | expect(policy.user).to eq user 290 | expect(policy.post).to eq Post 291 | end 292 | 293 | it "raises an error with a invalid policy constructor" do 294 | expect do 295 | Pundit.policy(user, Wiki) 296 | end.to raise_error(Pundit::InvalidConstructorError, "Invalid # constructor is called") 297 | end 298 | 299 | it "returns an instantiated policy given an array of a symbol and an active model class" do 300 | policy = Pundit.policy(user, [:project, Comment]) 301 | expect(policy.class).to eq Project::CommentPolicy 302 | expect(policy.user).to eq user 303 | expect(policy.comment).to eq Comment 304 | end 305 | 306 | it "returns an instantiated policy given an array of a symbol and a class with policy_class override" do 307 | policy = Pundit.policy(user, [:project, Customer::Post]) 308 | expect(policy.class).to eq Project::PostPolicy 309 | expect(policy.user).to eq user 310 | expect(policy.post).to eq Customer::Post 311 | end 312 | 313 | it "returns correct policy class for an array of a multi-word symbols" do 314 | policy = Pundit.policy(user, %i[project_one_two_three criteria_four_five_six]) 315 | expect(policy.class).to eq ProjectOneTwoThree::CriteriaFourFiveSixPolicy 316 | end 317 | 318 | it "returns correct policy class for an array of a multi-word symbol and a multi-word plain model instance" do 319 | policy = Pundit.policy(user, [:project_one_two_three, post_four_five_six]) 320 | expect(policy.class).to eq ProjectOneTwoThree::PostFourFiveSixPolicy 321 | end 322 | 323 | it "returns correct policy class for an array of a multi-word symbol and a multi-word active model instance" do 324 | policy = Pundit.policy(user, [:project_one_two_three, comment_four_five_six]) 325 | expect(policy.class).to eq ProjectOneTwoThree::CommentFourFiveSixPolicy 326 | end 327 | 328 | it "returns correct policy class for an array of a multi-word symbol and a multi-word plain model class" do 329 | policy = Pundit.policy(user, [:project_one_two_three, PostFourFiveSix]) 330 | expect(policy.class).to eq ProjectOneTwoThree::PostFourFiveSixPolicy 331 | end 332 | 333 | it "returns correct policy class for an array of a multi-word symbol and a multi-word active model class" do 334 | policy = Pundit.policy(user, [:project_one_two_three, CommentFourFiveSix]) 335 | expect(policy.class).to eq ProjectOneTwoThree::CommentFourFiveSixPolicy 336 | end 337 | 338 | it "returns correct policy class for a multi-word scoped plain model class" do 339 | policy = Pundit.policy(user, ProjectOneTwoThree::TagFourFiveSix) 340 | expect(policy.class).to eq ProjectOneTwoThree::TagFourFiveSixPolicy 341 | end 342 | 343 | it "returns correct policy class for a multi-word scoped plain model instance" do 344 | policy = Pundit.policy(user, tag_four_five_six) 345 | expect(policy.class).to eq ProjectOneTwoThree::TagFourFiveSixPolicy 346 | end 347 | 348 | it "returns correct policy class for a multi-word scoped active model class" do 349 | policy = Pundit.policy(user, ProjectOneTwoThree::AvatarFourFiveSix) 350 | expect(policy.class).to eq ProjectOneTwoThree::AvatarFourFiveSixPolicy 351 | end 352 | 353 | it "returns correct policy class for a multi-word scoped active model instance" do 354 | policy = Pundit.policy(user, avatar_four_five_six) 355 | expect(policy.class).to eq ProjectOneTwoThree::AvatarFourFiveSixPolicy 356 | end 357 | 358 | it "returns nil if the given policy can't be found" do 359 | expect(Pundit.policy(user, article)).to be_nil 360 | expect(Pundit.policy(user, Article)).to be_nil 361 | end 362 | 363 | it "returns the specified NilClassPolicy for nil" do 364 | expect(Pundit.policy(user, nil)).to be_a NilClassPolicy 365 | end 366 | 367 | describe "with .policy_class set on the model" do 368 | it "returns an instantiated policy given a plain model instance" do 369 | policy = Pundit.policy(user, artificial_blog) 370 | expect(policy.user).to eq user 371 | expect(policy.blog).to eq artificial_blog 372 | end 373 | 374 | it "returns an instantiated policy given a plain model class" do 375 | policy = Pundit.policy(user, ArtificialBlog) 376 | expect(policy.user).to eq user 377 | expect(policy.blog).to eq ArtificialBlog 378 | end 379 | 380 | it "returns an instantiated policy given a plain model instance providing an anonymous class" do 381 | policy = Pundit.policy(user, article_tag) 382 | expect(policy.user).to eq user 383 | expect(policy.tag).to eq article_tag 384 | end 385 | 386 | it "returns an instantiated policy given a plain model class providing an anonymous class" do 387 | policy = Pundit.policy(user, ArticleTag) 388 | expect(policy.user).to eq user 389 | expect(policy.tag).to eq ArticleTag 390 | end 391 | end 392 | end 393 | 394 | describe ".policy!" do 395 | it "returns an instantiated policy given a plain model instance" do 396 | policy = Pundit.policy!(user, post) 397 | expect(policy.user).to eq user 398 | expect(policy.post).to eq post 399 | end 400 | 401 | it "returns an instantiated policy given an active model instance" do 402 | policy = Pundit.policy!(user, comment) 403 | expect(policy.user).to eq user 404 | expect(policy.comment).to eq comment 405 | end 406 | 407 | it "returns an instantiated policy given a plain model class" do 408 | policy = Pundit.policy!(user, Post) 409 | expect(policy.user).to eq user 410 | expect(policy.post).to eq Post 411 | end 412 | 413 | it "returns an instantiated policy given an active model class" do 414 | policy = Pundit.policy!(user, Comment) 415 | expect(policy.user).to eq user 416 | expect(policy.comment).to eq Comment 417 | end 418 | 419 | it "returns an instantiated policy given a symbol" do 420 | policy = Pundit.policy!(user, :criteria) 421 | expect(policy.class).to eq CriteriaPolicy 422 | expect(policy.user).to eq user 423 | expect(policy.criteria).to eq :criteria 424 | end 425 | 426 | it "returns an instantiated policy given an array of symbols" do 427 | policy = Pundit.policy!(user, %i[project criteria]) 428 | expect(policy.class).to eq Project::CriteriaPolicy 429 | expect(policy.user).to eq user 430 | expect(policy.criteria).to eq :criteria 431 | end 432 | 433 | it "throws an exception if the given policy can't be found" do 434 | expect { Pundit.policy!(user, article) }.to raise_error(Pundit::NotDefinedError) 435 | expect { Pundit.policy!(user, Article) }.to raise_error(Pundit::NotDefinedError) 436 | end 437 | 438 | it "returns the specified NilClassPolicy for nil" do 439 | expect(Pundit.policy!(user, nil)).to be_a NilClassPolicy 440 | end 441 | 442 | it "raises an error with a invalid policy constructor" do 443 | expect do 444 | Pundit.policy(user, Wiki) 445 | end.to raise_error(Pundit::InvalidConstructorError, "Invalid # constructor is called") 446 | end 447 | end 448 | 449 | describe ".included" do 450 | it "includes Authorization module" do 451 | klass = Class.new 452 | 453 | expect do 454 | klass.include Pundit 455 | end.to output.to_stderr 456 | 457 | expect(klass).to include Pundit::Authorization 458 | end 459 | 460 | it "warns about deprecation" do 461 | klass = Class.new 462 | expect do 463 | klass.include Pundit 464 | end.to output(a_string_starting_with("'include Pundit' is deprecated")).to_stderr 465 | end 466 | end 467 | 468 | describe "Pundit::NotAuthorizedError" do 469 | it "can be initialized with a string as message" do 470 | error = Pundit::NotAuthorizedError.new("must be logged in") 471 | expect(error.message).to eq "must be logged in" 472 | end 473 | end 474 | end 475 | -------------------------------------------------------------------------------- /spec/rspec_dsl_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Pundit RSpec DSL" do 6 | include Pundit::RSpec::PolicyExampleGroup 7 | 8 | let(:fake_rspec) do 9 | double = class_double(RSpec::ExampleGroups) 10 | double.extend(::Pundit::RSpec::DSL) 11 | double 12 | end 13 | let(:block) { proc { "block content" } } 14 | 15 | let(:user) { double } 16 | let(:other_user) { double } 17 | let(:post) { Post.new(user) } 18 | let(:policy) { PostPolicy } 19 | 20 | it "calls describe with the correct metadata and without :focus" do 21 | expected_metadata = { permissions: %i[item1 item2], caller: instance_of(Array) } 22 | expect(fake_rspec).to receive(:describe).with("item1 and item2", match(expected_metadata)) do |&block| 23 | expect(block.call).to eq("block content") 24 | end 25 | 26 | fake_rspec.permissions(:item1, :item2, &block) 27 | end 28 | 29 | it "calls describe with the correct metadata and with :focus" do 30 | expected_metadata = { permissions: %i[item1 item2], caller: instance_of(Array), focus: true } 31 | expect(fake_rspec).to receive(:describe).with("item1 and item2", match(expected_metadata)) do |&block| 32 | expect(block.call).to eq("block content") 33 | end 34 | 35 | fake_rspec.permissions(:item1, :item2, :focus, &block) 36 | end 37 | 38 | describe "#permit" do 39 | context "when not appropriately wrapped in permissions" do 40 | it "raises a descriptive error" do 41 | expect do 42 | expect(policy).to permit(user, post) 43 | end.to raise_error(KeyError, <<~MSG.strip) 44 | No permissions in example metadata, did you forget to wrap with `permissions :show?, ...`? 45 | MSG 46 | end 47 | end 48 | 49 | permissions :edit?, :update? do 50 | it "succeeds when action is permitted" do 51 | expect(policy).to permit(user, post) 52 | end 53 | 54 | context "when it fails" do 55 | it "fails with a descriptive error message" do 56 | expect do 57 | expect(policy).to permit(other_user, post) 58 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, <<~MSG.strip) 59 | Expected PostPolicy to grant edit? and update? on Post but edit? and update? were not granted 60 | MSG 61 | end 62 | end 63 | 64 | context "when negated" do 65 | it "succeeds when action is not permitted" do 66 | expect(policy).not_to permit(other_user, post) 67 | end 68 | 69 | context "when it fails" do 70 | it "fails with a descriptive error message" do 71 | expect do 72 | expect(policy).not_to permit(user, post) 73 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, <<~MSG.strip) 74 | Expected PostPolicy not to grant edit? and update? on Post but edit? and update? were granted 75 | MSG 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/simple_cov_check_action_formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | require "json" 5 | 6 | class SimpleCovCheckActionFormatter 7 | SourceFile = Data.define(:source_file) do 8 | def covered_strength = source_file.covered_strength 9 | def covered_percent = source_file.covered_percent 10 | 11 | def to_json(*args) 12 | { 13 | filename: source_file.filename, 14 | covered_percent: covered_percent.nan? ? 0.0 : covered_percent, 15 | coverage: source_file.coverage_data, 16 | covered_strength: covered_strength.nan? ? 0.0 : covered_strength, 17 | covered_lines: source_file.covered_lines.count, 18 | lines_of_code: source_file.lines_of_code 19 | }.to_json(*args) 20 | end 21 | end 22 | 23 | Result = Data.define(:result) do 24 | def included?(source_file) = result.filenames.include?(source_file.filename) 25 | 26 | def files 27 | result.files.filter_map do |source_file| 28 | next unless result.filenames.include? source_file.filename 29 | 30 | SourceFile.new(source_file) 31 | end 32 | end 33 | 34 | def to_json(*args) # rubocop:disable Metrics/AbcSize 35 | { 36 | timestamp: result.created_at.to_i, 37 | command_name: result.command_name, 38 | files: files, 39 | metrics: { 40 | covered_percent: result.covered_percent, 41 | covered_strength: result.covered_strength.nan? ? 0.0 : result.covered_strength, 42 | covered_lines: result.covered_lines, 43 | total_lines: result.total_lines 44 | } 45 | }.to_json(*args) 46 | end 47 | end 48 | 49 | FormatterWithOptions = Data.define(:formatter) do 50 | def new = formatter 51 | end 52 | 53 | class << self 54 | def with_options(...) 55 | FormatterWithOptions.new(new(...)) 56 | end 57 | end 58 | 59 | def initialize(output_filename: "coverage.json", output_directory: SimpleCov.coverage_path) 60 | @output_filename = output_filename 61 | @output_directory = output_directory 62 | end 63 | 64 | attr_reader :output_filename, :output_directory 65 | 66 | def output_filepath = File.join(output_directory, output_filename) 67 | 68 | def format(result_data) 69 | result = Result.new(result_data) 70 | json = JSON.generate(result) 71 | File.write(output_filepath, json) 72 | puts output_message(result_data) 73 | json 74 | end 75 | 76 | def output_message(result) 77 | "Coverage report generated for #{result.command_name} to #{output_filepath}. #{result.covered_lines} / #{result.total_lines} LOC (#{result.covered_percent.round(2)}%) covered." # rubocop:disable Layout/LineLength 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV["COVERAGE"] 4 | require "simplecov" 5 | require "simplecov_json_formatter" 6 | require_relative "simple_cov_check_action_formatter" 7 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | SimpleCov::Formatter::JSONFormatter, 10 | SimpleCovCheckActionFormatter.with_options( 11 | output_filename: "simplecov-check-action.json" 12 | ) 13 | ]) 14 | SimpleCov.start do 15 | add_filter "/spec/" 16 | enable_coverage :branch 17 | primary_coverage :branch 18 | end 19 | end 20 | 21 | # @see https://github.com/rails/rails/issues/54260 22 | require "logger" if RUBY_ENGINE == "jruby" && RUBY_ENGINE_VERSION.start_with?("9.3") 23 | 24 | require "pundit" 25 | require "pundit/rspec" 26 | require "active_model/naming" 27 | 28 | # Load all supporting files: models, policies, etc. 29 | require "zeitwerk" 30 | loader = Zeitwerk::Loader.new 31 | loader.push_dir(File.expand_path("support/models", __dir__)) 32 | loader.push_dir(File.expand_path("support/policies", __dir__)) 33 | loader.push_dir(File.expand_path("support/lib", __dir__)) 34 | loader.setup 35 | loader.eager_load 36 | -------------------------------------------------------------------------------- /spec/support/lib/controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Controller 4 | attr_accessor :current_user 5 | attr_reader :action_name, :params 6 | 7 | class View 8 | def initialize(controller) 9 | @controller = controller 10 | end 11 | 12 | attr_reader :controller 13 | end 14 | 15 | class << self 16 | def helper(mod) 17 | View.include(mod) 18 | end 19 | 20 | def helper_method(method) 21 | View.class_eval <<-RUBY, __FILE__, __LINE__ + 1 22 | def #{method}(*args, **kwargs, &block) 23 | controller.send(:#{method}, *args, **kwargs, &block) 24 | end 25 | RUBY 26 | end 27 | end 28 | 29 | include Pundit::Authorization 30 | # Mark protected methods public so they may be called in test 31 | public(*Pundit::Authorization.protected_instance_methods) 32 | 33 | def initialize(current_user, action_name, params) 34 | @current_user = current_user 35 | @action_name = action_name 36 | @params = params 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/lib/custom_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CustomCache 4 | def initialize 5 | @store = {} 6 | end 7 | 8 | def to_h 9 | @store 10 | end 11 | 12 | def [](key) 13 | @store[key] 14 | end 15 | 16 | def []=(key, value) 17 | @store[key] = value 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/lib/instance_tracking.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InstanceTracking 4 | module ClassMethods 5 | def instances 6 | @instances || 0 7 | end 8 | 9 | attr_writer :instances 10 | end 11 | 12 | def self.prepended(other) 13 | other.extend(ClassMethods) 14 | end 15 | 16 | def initialize(*args, **kwargs, &block) 17 | self.class.instances += 1 18 | super(*args, **kwargs, &block) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/models/article.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Article 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/article_tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ArticleTag 4 | def self.policy_class 5 | ArticleTagOtherNamePolicy 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/artificial_blog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ArtificialBlog < Blog 4 | def self.policy_class 5 | BlogPolicy 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/blog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Blog 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Comment 4 | extend ActiveModel::Naming 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/models/comment_four_five_six.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentFourFiveSix 4 | extend ActiveModel::Naming 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/models/comment_scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentScope 4 | attr_reader :original_object 5 | 6 | def initialize(original_object) 7 | @original_object = original_object 8 | end 9 | 10 | def ==(other) 11 | original_object == other.original_object 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/models/comments_relation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentsRelation 4 | def initialize(empty: false) 5 | @empty = empty 6 | end 7 | 8 | def blank? 9 | @empty 10 | end 11 | 12 | def self.model_name 13 | Comment.model_name 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/models/customer/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Customer 4 | class Post < ::Post 5 | extend ActiveModel::Naming 6 | 7 | def self.policy_class 8 | PostPolicy 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/models/default_scope_contains_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DefaultScopeContainsError 4 | def self.all; end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/models/dummy_current_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyCurrentUser 4 | def update? 5 | user 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/foo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Foo 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Post 4 | def initialize(user = nil) 5 | @user = user 6 | end 7 | 8 | attr_reader :user 9 | 10 | def self.published 11 | :published 12 | end 13 | 14 | def self.read 15 | :read 16 | end 17 | 18 | def to_s 19 | "Post" 20 | end 21 | 22 | def inspect 23 | "#" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/models/post_four_five_six.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PostFourFiveSix 4 | def initialize(user) 5 | @user = user 6 | end 7 | 8 | attr_reader(:user) 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/models/project_one_two_three/avatar_four_five_six.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class AvatarFourFiveSix 5 | extend ActiveModel::Naming 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/project_one_two_three/tag_four_five_six.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class TagFourFiveSix 5 | def initialize(user) 6 | @user = user 7 | end 8 | 9 | attr_reader(:user) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/models/wiki.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Wiki 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/policies/article_tag_other_name_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ArticleTagOtherNamePolicy < BasePolicy 4 | def show? 5 | true 6 | end 7 | 8 | def destroy? 9 | false 10 | end 11 | 12 | alias tag record 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/policies/base_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BasePolicy 4 | prepend InstanceTracking 5 | 6 | class BaseScope 7 | prepend InstanceTracking 8 | 9 | def initialize(user, scope) 10 | @user = user 11 | @scope = scope 12 | end 13 | 14 | attr_reader :user, :scope 15 | end 16 | 17 | def initialize(user, record) 18 | @user = user 19 | @record = record 20 | end 21 | 22 | attr_reader :user, :record 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/policies/blog_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BlogPolicy < BasePolicy 4 | alias blog record 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/policies/comment_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentPolicy < BasePolicy 4 | class Scope < BaseScope 5 | def resolve 6 | CommentScope.new(scope) 7 | end 8 | end 9 | 10 | alias comment record 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/policies/criteria_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CriteriaPolicy < BasePolicy 4 | alias criteria record 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/policies/default_scope_contains_error_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DefaultScopeContainsErrorPolicy < BasePolicy 4 | class Scope < BaseScope 5 | def resolve 6 | # deliberate wrong usage of the method 7 | raise "This is an arbitrary error that should bubble up" 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/policies/denier_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DenierPolicy < BasePolicy 4 | def update? 5 | false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/policies/dummy_current_user_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyCurrentUserPolicy < BasePolicy 4 | class Scope < BasePolicy::BaseScope 5 | def resolve 6 | user 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/policies/nil_class_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NilClassPolicy < BasePolicy 4 | class Scope 5 | def initialize(*) 6 | raise Pundit::NotDefinedError, "Cannot scope NilClass" 7 | end 8 | end 9 | 10 | def show? 11 | false 12 | end 13 | 14 | def destroy? 15 | false 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/policies/post_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PostPolicy < BasePolicy 4 | class Scope < BaseScope 5 | def resolve 6 | scope.published 7 | end 8 | end 9 | 10 | alias post record 11 | 12 | def update? 13 | post.user == user 14 | end 15 | alias edit? update? 16 | 17 | def destroy? 18 | false 19 | end 20 | 21 | def show? 22 | true 23 | end 24 | 25 | def permitted_attributes 26 | if post.user == user 27 | %i[title votes] 28 | else 29 | [:votes] 30 | end 31 | end 32 | 33 | def permitted_attributes_for_revise 34 | [:body] 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/policies/project/admin/comment_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Project 4 | module Admin 5 | class CommentPolicy < BasePolicy 6 | def update? 7 | true 8 | end 9 | 10 | def destroy? 11 | false 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/policies/project/comment_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Project 4 | class CommentPolicy < BasePolicy 5 | class Scope < BaseScope 6 | def resolve 7 | scope 8 | end 9 | end 10 | 11 | def update? 12 | true 13 | end 14 | 15 | alias comment record 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/policies/project/criteria_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Project 4 | class CriteriaPolicy < BasePolicy 5 | alias criteria record 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/policies/project/post_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Project 4 | class PostPolicy < BasePolicy 5 | class Scope < BaseScope 6 | def resolve 7 | scope.read 8 | end 9 | end 10 | 11 | alias post record 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/policies/project_one_two_three/avatar_four_five_six_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class AvatarFourFiveSixPolicy < BasePolicy 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/policies/project_one_two_three/comment_four_five_six_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class CommentFourFiveSixPolicy < BasePolicy 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/policies/project_one_two_three/criteria_four_five_six_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class CriteriaFourFiveSixPolicy < BasePolicy 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/policies/project_one_two_three/post_four_five_six_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class PostFourFiveSixPolicy < BasePolicy 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/policies/project_one_two_three/tag_four_five_six_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ProjectOneTwoThree 4 | class TagFourFiveSixPolicy < BasePolicy 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/policies/publication_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PublicationPolicy < BasePolicy 4 | class Scope < BaseScope 5 | def resolve 6 | scope.published 7 | end 8 | end 9 | 10 | def create? 11 | true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/policies/wiki_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class WikiPolicy 4 | class Scope 5 | # deliberate typo method 6 | def initalize; end 7 | end 8 | end 9 | --------------------------------------------------------------------------------