├── .all-contributorsrc ├── .editorconfig ├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .travis.yml ├── Appraisals ├── Gemfile ├── LICENSE.txt ├── README-old.md ├── README.md ├── Rakefile ├── bin ├── console ├── phare ├── rubocop └── setup ├── docs └── relationship-authorization.md ├── gemfiles ├── rails_5_2_pundit_1.gemfile ├── rails_5_2_pundit_2.gemfile ├── rails_6_0_pundit_1.gemfile └── rails_6_0_pundit_2.gemfile ├── jsonapi-authorization.gemspec ├── lib ├── jsonapi-authorization.rb └── jsonapi │ ├── authorization.rb │ └── authorization │ ├── authorizing_processor.rb │ ├── configuration.rb │ ├── default_pundit_authorizer.rb │ ├── pundit_scoped_resource.rb │ └── version.rb └── spec ├── dummy ├── .gitignore ├── Rakefile ├── app │ ├── assets │ │ └── config │ │ │ └── manifest.js │ ├── controllers │ │ ├── application_controller.rb │ │ ├── articles_controller.rb │ │ ├── comments_controller.rb │ │ ├── taggable_controller.rb │ │ ├── tags_controller.rb │ │ └── users_controller.rb │ ├── models │ │ ├── article.rb │ │ ├── comment.rb │ │ ├── tag.rb │ │ └── user.rb │ ├── policies │ │ ├── article_policy.rb │ │ ├── comment_policy.rb │ │ ├── tag_policy.rb │ │ └── user_policy.rb │ └── resources │ │ ├── article_resource.rb │ │ ├── comment_resource.rb │ │ ├── tag_resource.rb │ │ ├── taggable_resource.rb │ │ └── user_resource.rb ├── bin │ ├── rails │ └── rake ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ └── routes.rb ├── db │ ├── migrate │ │ └── 20160125083537_create_models.rb │ └── schema.rb └── log │ └── .keep ├── fixtures ├── articles.yml ├── comments.yml ├── tags.yml └── users.yml ├── jsonapi └── authorization │ ├── configuration_spec.rb │ └── default_pundit_authorizer_spec.rb ├── requests ├── custom_name_relationship_operations_spec.rb ├── included_resources_spec.rb ├── related_resources_operations_spec.rb ├── relationship_operations_spec.rb ├── resource_operations_spec.rb └── tricky_operations_spec.rb ├── spec_helper.rb └── support ├── authorization_stubs.rb ├── custom_matchers.rb └── pundit_stubs.rb /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "jsonapi-authorization", 3 | "projectOwner": "Venuu", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": true, 9 | "contributors": [ 10 | { 11 | "login": "valscion", 12 | "name": "Vesa Laakso", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/482561?v=3", 14 | "profile": "http://vesalaakso.com", 15 | "contributions": [ 16 | "code", 17 | "doc", 18 | "infra", 19 | "test", 20 | "bug", 21 | "question", 22 | "review" 23 | ] 24 | }, 25 | { 26 | "login": "lime", 27 | "name": "Emil Sågfors", 28 | "avatar_url": "https://avatars.githubusercontent.com/u/562204?v=3", 29 | "profile": "https://github.com/lime", 30 | "contributions": [ 31 | "code", 32 | "doc", 33 | "infra", 34 | "test", 35 | "bug", 36 | "question", 37 | "review" 38 | ] 39 | }, 40 | { 41 | "login": "matthias-g", 42 | "name": "Matthias Grundmann", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/1591161?v=3", 44 | "profile": "https://github.com/matthias-g", 45 | "contributions": [ 46 | "code", 47 | "doc", 48 | "test", 49 | "question" 50 | ] 51 | }, 52 | { 53 | "login": "thibaudgg", 54 | "name": "Thibaud Guillaume-Gentil", 55 | "avatar_url": "https://avatars.githubusercontent.com/u/1322?v=3", 56 | "profile": "http://thibaud.gg", 57 | "contributions": [ 58 | "code" 59 | ] 60 | }, 61 | { 62 | "login": "acid", 63 | "name": "Daniel Schweighöfer", 64 | "avatar_url": "https://avatars.githubusercontent.com/u/71660?v=3", 65 | "profile": "http://netsteward.net", 66 | "contributions": [ 67 | "code" 68 | ] 69 | }, 70 | { 71 | "login": "bsofiato", 72 | "name": "Bruno Sofiato", 73 | "avatar_url": "https://avatars.githubusercontent.com/u/5076967?v=3", 74 | "profile": "https://github.com/bsofiato", 75 | "contributions": [ 76 | "code" 77 | ] 78 | }, 79 | { 80 | "login": "arcreative", 81 | "name": "Adam Robertson", 82 | "avatar_url": "https://avatars.githubusercontent.com/u/1896026?v=3", 83 | "profile": "https://github.com/arcreative", 84 | "contributions": [ 85 | "doc" 86 | ] 87 | }, 88 | { 89 | "login": "gnfisher", 90 | "name": "Greg Fisher", 91 | "avatar_url": "https://avatars3.githubusercontent.com/u/4742306?v=3", 92 | "profile": "https://github.com/gnfisher", 93 | "contributions": [ 94 | "code", 95 | "test" 96 | ] 97 | }, 98 | { 99 | "login": "handlers", 100 | "name": "Sam", 101 | "avatar_url": "https://avatars3.githubusercontent.com/u/370182?v=3", 102 | "profile": "http://samlh.com", 103 | "contributions": [ 104 | "code", 105 | "test" 106 | ] 107 | }, 108 | { 109 | "login": "jpalumickas", 110 | "name": "Justas Palumickas", 111 | "avatar_url": "https://avatars0.githubusercontent.com/u/2738630?v=3", 112 | "profile": "https://jpalumickas.com", 113 | "contributions": [ 114 | "bug", 115 | "code", 116 | "test" 117 | ] 118 | }, 119 | { 120 | "login": "nruth", 121 | "name": "Nicholas Rutherford", 122 | "avatar_url": "https://avatars1.githubusercontent.com/u/26158?v=4", 123 | "profile": "http://www.google.co.uk/profiles/nick.rutherford", 124 | "contributions": [ 125 | "code", 126 | "test", 127 | "infra" 128 | ] 129 | }, 130 | { 131 | "login": "Matthijsy", 132 | "name": "Matthijsy", 133 | "avatar_url": "https://avatars2.githubusercontent.com/u/5302372?v=4", 134 | "profile": "https://github.com/Matthijsy", 135 | "contributions": [ 136 | "bug", 137 | "test", 138 | "code" 139 | ] 140 | }, 141 | { 142 | "login": "brianswko", 143 | "name": "brianswko", 144 | "avatar_url": "https://avatars0.githubusercontent.com/u/3952486?v=4", 145 | "profile": "https://github.com/brianswko", 146 | "contributions": [ 147 | "bug", 148 | "test", 149 | "code" 150 | ] 151 | } 152 | ], 153 | "repoType": "github", 154 | "contributorsPerLine": 7 155 | } 156 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-20.04 19 | strategy: 20 | matrix: 21 | gemfile: 22 | - rails_5_2_pundit_1 23 | - rails_5_2_pundit_2 24 | - rails_6_0_pundit_1 25 | - rails_6_0_pundit_2 26 | ruby-version: [ '2.6', '2.7' ] 27 | include: 28 | # Include Rails 6.0 / Ruby 3.0 combo 29 | - gemfile: rails_6_0_pundit_1 30 | ruby-version: '3.0' 31 | - gemfile: rails_6_0_pundit_2 32 | ruby-version: '3.0' 33 | continue-on-error: true 34 | env: 35 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Set up Ruby 39 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 40 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: ${{ matrix.ruby-version }} 44 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 45 | - name: Run tests 46 | run: bundle exec rake 47 | code-style: 48 | runs-on: ubuntu-20.04 49 | steps: 50 | - uses: actions/checkout@v2 51 | - name: Set up Ruby 52 | uses: ruby/setup-ruby@v1 53 | with: 54 | ruby-version: 2.7 55 | bundler-cache: true 56 | - name: Verify code style 57 | run: bundle exec phare 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /spec/dummy/tmp/ 11 | *.orig 12 | .ruby-version 13 | /gemfiles/*.gemfile.lock 14 | /gemfiles/.bundle/ 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.7 5 | NewCops: enable 6 | SuggestExtensions: false 7 | Exclude: 8 | - 'bin/*' 9 | - 'gemfiles/*' 10 | - 'spec/dummy/db/schema.rb' 11 | - 'vendor/bundle/**/*' 12 | - 'tmp/**/*' 13 | 14 | # TODO: Define a spec.required_ruby_version when shipping a new version 15 | Gemspec/RequiredRubyVersion: 16 | Enabled: false 17 | 18 | Naming/FileName: 19 | Exclude: 20 | - lib/jsonapi-authorization.rb 21 | - Appraisals 22 | 23 | Layout/LineLength: 24 | Enabled: true 25 | Max: 100 26 | Exclude: 27 | - spec/requests/**/*.rb 28 | - jsonapi-authorization.gemspec 29 | 30 | Layout/MultilineOperationIndentation: 31 | EnforcedStyle: indented 32 | 33 | Layout/MultilineMethodCallIndentation: 34 | EnforcedStyle: indented 35 | 36 | # We don't want rubocop to enforce splitting methods and stuff like that. 37 | Metrics: 38 | Enabled: false 39 | 40 | # We don't care what kind of quotes you use 41 | Style/StringLiterals: 42 | Enabled: false 43 | 44 | # It's up to us how much we want to document the code 45 | Style/Documentation: 46 | Enabled: false 47 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-08-24 19:47:54 UTC using RuboCop version 1.35.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | branches: 3 | only: 4 | - master 5 | jobs: 6 | include: 7 | - rvm: 2.3 8 | gemfile: gemfiles/rails_4_2_pundit_1.gemfile 9 | - rvm: 2.3 10 | gemfile: gemfiles/rails_4_2_pundit_2.gemfile 11 | - rvm: 2.5 12 | gemfile: gemfiles/rails_5_0_pundit_1.gemfile 13 | - rvm: 2.5 14 | gemfile: gemfiles/rails_5_0_pundit_2.gemfile 15 | - rvm: 2.5 16 | gemfile: gemfiles/rails_5_1_pundit_1.gemfile 17 | - rvm: 2.5 18 | gemfile: gemfiles/rails_5_1_pundit_2.gemfile 19 | - rvm: 2.5 20 | gemfile: gemfiles/rails_5_2_pundit_1.gemfile 21 | - rvm: 2.5 22 | gemfile: gemfiles/rails_5_2_pundit_2.gemfile 23 | - rvm: 2.5 24 | gemfile: gemfiles/rails_6_0_pundit_1.gemfile 25 | - rvm: 2.5 26 | gemfile: gemfiles/rails_6_0_pundit_2.gemfile 27 | before_install: 28 | - gem install bundler -v '< 2' 29 | notifications: 30 | email: false 31 | script: 32 | - ./bin/phare 33 | - bundle exec rake 34 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-5-2 pundit-1' do 4 | gem 'rails', '5.2.4.4' 5 | gem 'jsonapi-resources', '~> 0.9.0' 6 | gem 'pundit', '~> 1.0' 7 | group :development, :test do 8 | gem 'sqlite3', '~> 1.3.13' 9 | end 10 | end 11 | 12 | appraise 'rails-6-0 pundit-1' do 13 | gem 'rails', '~> 6.0.3.4' 14 | gem 'jsonapi-resources', '~> 0.9.0' 15 | gem 'pundit', '~> 1.0' 16 | group :development, :test do 17 | gem 'sqlite3', '~> 1.4.1' 18 | end 19 | end 20 | 21 | appraise 'rails-5-2 pundit-2' do 22 | gem 'rails', '5.2.4.4' 23 | gem 'jsonapi-resources', '~> 0.9.0' 24 | gem 'pundit', '~> 2.0' 25 | group :development, :test do 26 | gem 'sqlite3', '~> 1.3.13' 27 | end 28 | end 29 | 30 | appraise 'rails-6-0 pundit-2' do 31 | gem 'rails', '~> 6.0.3.4' 32 | gem 'jsonapi-resources', '~> 0.9.0' 33 | gem 'pundit', '~> 2.0' 34 | group :development, :test do 35 | gem 'sqlite3', '~> 1.4.1' 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Vesa Laakso, Emil Sågfors 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-old.md: -------------------------------------------------------------------------------- 1 | # JSONAPI::Authorization 2 | 3 | [![Build Status](https://img.shields.io/travis/com/venuu/jsonapi-authorization/master.svg?style=flat&maxAge=3600)](https://travis-ci.com/venuu/jsonapi-authorization) [![Gem Version](https://img.shields.io/gem/v/jsonapi-authorization.svg?style=flat&maxAge=3600)](https://rubygems.org/gems/jsonapi-authorization) 4 | 5 | **NOTE:** This README is the documentation for `JSONAPI::Authorization`. If you are viewing this at the 6 | [project page on Github](https://github.com/venuu/jsonapi-authorization) you are viewing the documentation for the `master` 7 | branch. This may contain information that is not relevant to the release you are using. Please see the README for the 8 | [version](https://github.com/venuu/jsonapi-authorization/releases) you are using. 9 | 10 | --- 11 | 12 | `JSONAPI::Authorization` adds authorization to the [jsonapi-resources][jr] (JR) gem using [Pundit][pundit]. 13 | 14 | [jr]: https://github.com/cerebris/jsonapi-resources "A resource-focused Rails library for developing JSON API compliant servers." 15 | [pundit]: https://github.com/elabs/pundit "Minimal authorization through OO design and pure Ruby classes" 16 | 17 | The core design principle of `JSONAPI::Authorization` is: 18 | 19 | **Prefer being overly restrictive rather than too permissive by accident.** 20 | 21 | What follows is that we want to have: 22 | 23 | 1. Whitelist over blacklist -approach for authorization 24 | 2. Fall back on a more strict authorization 25 | 26 | ## Caveats 27 | 28 | Make sure to test for authorization in your application, too. We should have coverage of all operations, though. If that isn't the case, please [open an issue][issues]. 29 | 30 | If you're using custom processors, make sure that they extend `JSONAPI::Authorization::AuthorizingProcessor`, or authorization will not be performed for that resource. 31 | 32 | This gem should work out-of-the box for simple cases. The default authorizer might be overly restrictive for cases where you are touching relationships. 33 | 34 | **If you are modifying relationships**, you should read the [relationship authorization documentation](docs/relationship-authorization.md). 35 | 36 | ## Installation 37 | 38 | Add this line to your application's Gemfile: 39 | 40 | ```ruby 41 | gem 'jsonapi-authorization' 42 | ``` 43 | 44 | And then execute: 45 | 46 | $ bundle 47 | 48 | Or install it yourself as: 49 | 50 | $ gem install jsonapi-authorization 51 | 52 | ## Compatibility 53 | 54 | * `v0.6.x` supports JR `v0.7.x` 55 | * `v0.8.x` supports JR `v0.8.x` 56 | * Later releases support JR `v0.9.x` 57 | * **JR `v0.10.x` is NOT SUPPORTED.** See https://github.com/venuu/jsonapi-authorization/issues/64 for more details and to offer help. 58 | 59 | We aim to support the same Ruby and Ruby on Rails versions as `jsonapi-resources` does. If that's not the case, please [open an issue][issues]. 60 | 61 | ## Versioning and changelog 62 | 63 | `jsonapi-authorization` follows [Semantic Versioning](https://semver.org/). We prefer to make more major version bumps when we do changes that are likely to be backwards incompatible. That holds true even when it's likely the changes would be backwards compatible for a majority of our users. 64 | 65 | Given the nature of an authorization library, it is likely that most changes are major version bumps. 66 | 67 | Whenever we do changes, we strive to write good changelogs in the [GitHub releases page](https://github.com/venuu/jsonapi-authorization/releases). 68 | 69 | ## Usage 70 | 71 | First make sure you have a Pundit policy specified for every backing model that your JR resources use. 72 | 73 | Hook up this gem as the default processor for JR, and optionally allow rescuing from `Pundit::NotAuthorizedError` to output better errors for unauthorized requests: 74 | 75 | ```ruby 76 | # config/initializers/jsonapi-resources.rb 77 | JSONAPI.configure do |config| 78 | config.default_processor_klass = JSONAPI::Authorization::AuthorizingProcessor 79 | config.exception_class_whitelist = [Pundit::NotAuthorizedError] 80 | end 81 | ``` 82 | 83 | Make all your JR controllers specify the user in the `context` and rescue errors thrown by unauthorized requests: 84 | 85 | ```ruby 86 | class BaseResourceController < ActionController::Base 87 | include JSONAPI::ActsAsResourceController 88 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 89 | 90 | private 91 | 92 | def context 93 | {user: current_user} 94 | end 95 | 96 | def user_not_authorized 97 | head :forbidden 98 | end 99 | end 100 | ``` 101 | 102 | Have your JR resources include the `JSONAPI::Authorization::PunditScopedResource` module. 103 | 104 | ```ruby 105 | class BaseResource < JSONAPI::Resource 106 | include JSONAPI::Authorization::PunditScopedResource 107 | abstract 108 | end 109 | ``` 110 | 111 | ### Policies 112 | 113 | To check whether an action is allowed JSONAPI::Authorization calls the respective actions of your pundit policies 114 | (`index?`, `show?`, `create?`, `update?`, `destroy?`). 115 | 116 | For relationship operations by default `update?` is being called for all affected resources. 117 | For a finer grained control you can define methods to authorize relationship changes. For example: 118 | 119 | ```ruby 120 | class ArticlePolicy 121 | 122 | # (...) 123 | 124 | def add_to_comments?(new_comments) 125 | record.published && new_comments.all? { |comment| comment.author == user } 126 | end 127 | 128 | def replace_comments?(new_comments) 129 | allowed = record.comments.all? { |comment| new_comments.include?(comment) || add_to_comments?([comment])} 130 | allowed && new_comments.all? { |comment| record.comments.include?(comment) || remove_from_comments?(comment) } 131 | end 132 | 133 | def remove_from_comments?(comment) 134 | comment.author == user || user.admin? 135 | end 136 | end 137 | ``` 138 | 139 | For thorough documentation about custom policy methods, check out the [relationship authorization docs](docs/relationship-authorization.md). 140 | 141 | ## Configuration 142 | 143 | You can use a custom authorizer class by specifying a configure block in an initializer file. If using a custom authorizer class, be sure to require them at the top of the initializer before usage. 144 | 145 | ```ruby 146 | JSONAPI::Authorization.configure do |config| 147 | config.authorizer = MyCustomAuthorizer 148 | end 149 | ``` 150 | 151 | By default JSONAPI::Authorization uses the `:user` key from the JSONAPI context hash as the Pundit user. If you would like to use `:current_user` or some other key, it can be configured as well. 152 | 153 | ```ruby 154 | JSONAPI::Authorization.configure do |config| 155 | config.pundit_user = :current_user 156 | # or a block can be provided 157 | config.pundit_user = ->(context){ context[:current_user] } 158 | end 159 | ``` 160 | 161 | ## Troubleshooting 162 | 163 | ### "Unable to find policy" exception for a request 164 | 165 | The exception might look like this for resource class `ArticleResource` that is backed by `Article` model: 166 | 167 | ``` 168 | unable to find policy `ArticlePolicy` for `Article' 169 | ``` 170 | 171 | This means that you don't have a policy class created for your model. Create one and the error should go away. 172 | 173 | ## Development 174 | 175 | After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 176 | 177 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 178 | 179 | ## Credits 180 | 181 | Originally based on discussion and code samples by [@barelyknown](https://github.com/barelyknown) and others in [cerebris/jsonapi-resources#16](https://github.com/cerebris/jsonapi-resources/issues/16). 182 | 183 | ## Contributing 184 | 185 | Bug reports and pull requests are welcome on GitHub at https://github.com/venuu/jsonapi-authorization. 186 | 187 | [issues]: https://github.com/venuu/jsonapi-authorization/issues 188 | 189 | ## Contributors 190 | 191 | Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 192 | 193 | 194 | 195 |
Vesa Laakso
Vesa Laakso

💻 📖 🚇 ⚠️ 🐛 💬 👀
Emil Sågfors
Emil Sågfors

💻 📖 🚇 ⚠️ 🐛 💬 👀
Matthias Grundmann
Matthias Grundmann

💻 📖 ⚠️ 💬
Thibaud Guillaume-Gentil
Thibaud Guillaume-Gentil

💻
Daniel Schweighöfer
Daniel Schweighöfer

💻
Bruno Sofiato
Bruno Sofiato

💻
Adam Robertson
Adam Robertson

📖
Greg Fisher
Greg Fisher

💻 ⚠️
Sam
Sam

💻 ⚠️
Justas Palumickas
Justas Palumickas

🐛 💻 ⚠️
Nicholas Rutherford
Nicholas Rutherford

💻 ⚠️ 🚇
Matthijsy
Matthijsy

🐛 ⚠️ 💻
brianswko
brianswko

🐛 ⚠️ 💻
196 | 197 | 198 | 199 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONAPI::Authorization 2 | 3 | This gem is no longer maintained. 4 | 5 | To view the old readme, head to [README-old.md](./README-old.md). 6 | 7 | After a series of attempts, we never got `jsonapi-authorization` to be compatible with `jsonapi-resources`: https://github.com/venuu/jsonapi-authorization/issues/64 8 | 9 | It is time to say goodbye. It was a good ride. 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "rails/all" 5 | require "jsonapi/authorization" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "pry" 15 | Pry.start 16 | -------------------------------------------------------------------------------- /bin/phare: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'phare' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("phare", "phare") 18 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rubocop' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("rubocop", "rubocop") 18 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docs/relationship-authorization.md: -------------------------------------------------------------------------------- 1 | # Authorization of operations touching relationships 2 | 3 | `JSONAPI::Authorization` (JA) is unique in the way it considers relationship changes to change the underlying models. Whenever an incoming request changes associated resources, JA will authorize those operations are OK. 4 | 5 | As JA runs the authorization checks _before_ any changes are made (even to in-memory objects), Pundit policies don't have the information needed to authorize changes to relationships. This is why JA provides special hooks to authorize relationship changes and falls back to checking `#update?` on all the related records. 6 | 7 | Caveat: In case a relationship is modifiable through multiple ways it is your responsibility to ensure consistency. 8 | For example if you have a many-to-many relationship with users and projects make sure that 9 | `ProjectPolicy#add_to_users?(users)` and `UserPolicy#add_to_projects?(projects)` match up. 10 | 11 | **Table of contents** 12 | 13 | * `has-one` relationships 14 | - [Example setup for `has-one` examples](#example-setup-has-one) 15 | - [`PATCH /articles/article-1/relationships/author`](#change-has-one-relationship-op) 16 | * Changing a `has-one` relationship 17 | - [`DELETE /articles/article-1/relationships/author`](#remove-has-one-relationship-op) 18 | * Removing a `has-one` relationship 19 | - [`PATCH /articles/article-1/` with different `author` relationship](#change-and-replace-has-one-resource-op) 20 | * Changing resource and replacing a `has-one` relationship 21 | - [`PATCH /articles/article-1/` with null `author` relationship](#change-and-remove-has-one-resource-op) 22 | * Changing resource and removing a `has-one` relationship 23 | - [`POST /articles` with an `author` relationship](#create-has-one-resource-op) 24 | * Creating a resource with a `has-one` relationship 25 | * `has-many` relationships 26 | - [Example setup for `has-many` examples](#example-setup-has-many) 27 | - [`POST /articles/article-1/relationships/comments`](#add-to-has-many-relationship-op) 28 | * Adding to a `has-many` relationship 29 | - [`DELETE /articles/article-1/relationships/comments`](#remove-from-has-many-relationship-op) 30 | * Removing from a `has-many` relationship 31 | - [`PATCH /articles/article-1/relationships/comments` with different `comments`](#replace-has-many-relationship-op) 32 | * Replacing a `has-many` relationship 33 | - [`PATCH /articles/article-1/relationships/comments` with empty `comments`](#remove-has-many-relationship-op) 34 | * Removing a `has-many` relationship 35 | - [`PATCH /articles/article-1` with different `comments` relationship](#change-and-replace-has-many-resource-op) 36 | * Changing resource and replacing a `has-many` relationship 37 | - [`PATCH /articles/article-1` with empty `comments` relationship](#change-and-remove-has-many-resource-op) 38 | * Changing resource and removing a `has-many` relationship 39 | - [`POST /articles` with a `comments` relationship](#create-has-many-resource-op) 40 | * Creating a resource with a `has-many` relationship 41 | 42 | 43 | 44 | 45 | [back to top ↑](#doc-top) 46 | 47 | ## `has-one` relationships 48 | 49 | ### Example setup for `has-one` examples 50 | 51 | The examples for `has-one` relationship authorization use these models and resources: 52 | 53 | ```rb 54 | class Article < ActiveRecord::Base 55 | belongs_to :author, class_name: 'User' 56 | end 57 | 58 | class ArticleResource < JSONAPI::Resource 59 | include JSONAPI::Authorization::PunditScopedResource 60 | has_one :author, class_name: 'User' 61 | end 62 | ``` 63 | 64 | ```rb 65 | class User < ActiveRecord::Base 66 | has_many :articles, foreign_key: :author_id 67 | end 68 | 69 | class UserResource < JSONAPI::Resource 70 | include JSONAPI::Authorization::PunditScopedResource 71 | has_many :articles 72 | end 73 | ``` 74 | 75 | 76 | 77 | [back to top ↑](#doc-top) 78 | 79 | ### `PATCH /articles/article-1/relationships/author` 80 | 81 | _Changing a `has-one` relationship with a relationship operation_ 82 | 83 | Setup: 84 | 85 | ```rb 86 | user_1 = User.create(id: 'user-1') 87 | article_1 = Article.create(id: 'article-1', author: user_1) 88 | user_2 = User.create(id: 'user-2') 89 | ``` 90 | 91 | > `PATCH /articles/article-1/relationships/author` 92 | > 93 | > ```json 94 | > { 95 | > "type": "users", 96 | > "id": "user-2" 97 | > } 98 | > ``` 99 | 100 | #### Custom relationship authorization method 101 | 102 | * `ArticlePolicy.new(current_user, article_1).replace_author?(user_2)` 103 | 104 | #### Fallback 105 | 106 | * `ArticlePolicy.new(current_user, article_1).update?` 107 | * `UserPolicy.new(current_user, user_2).update?` 108 | 109 | **Note:** Currently JA does not fallback to authorizing `UserPolicy#update?` on `user_1` that is about to be dissociated. This will likely be changed in the future. 110 | 111 | 112 | 113 | [back to top ↑](#doc-top) 114 | 115 | ### `DELETE /articles/article-1/relationships/author` 116 | 117 | _Removing a `has-one` relationship with a relationship operation_ 118 | 119 | Setup: 120 | 121 | ```rb 122 | user_1 = User.create(id: 'user-1') 123 | article_1 = Article.create(id: 'article-1', author: user_1) 124 | ``` 125 | 126 | > `DELETE /articles/article-1/relationships/author` 127 | > 128 | > (empty body) 129 | 130 | #### Custom relationship authorization method 131 | 132 | * `ArticlePolicy.new(current_user, article_1).remove_author?` 133 | 134 | #### Fallback 135 | 136 | * `ArticlePolicy.new(current_user, article_1).update?` 137 | 138 | **Note:** Currently JA does not fallback to authorizing `UserPolicy#update?` on `user_1` that is about to be dissociated. This will likely be changed in the future. 139 | 140 | 141 | 142 | [back to top ↑](#doc-top) 143 | 144 | ### `PATCH /articles/article-1/` with different `author` relationship 145 | 146 | _Changing resource and replacing a `has-one` relationship_ 147 | 148 | Setup: 149 | 150 | ```rb 151 | user_1 = User.create(id: 'user-1') 152 | article_1 = Article.create(id: 'article-1', author: user_1) 153 | user_2 = User.create(id: 'user-2') 154 | ``` 155 | 156 | > `PATCH /articles/article-1` 157 | > 158 | > ```json 159 | > { 160 | > "type": "articles", 161 | > "id": "article-1", 162 | > "relationships": { 163 | > "author": { 164 | > "data": { 165 | > "type": "users", 166 | > "id": "user-2" 167 | > } 168 | > } 169 | > } 170 | > } 171 | > ``` 172 | 173 | #### Always calls 174 | 175 | * `ArticlePolicy.new(current_user, article_1).update?` 176 | 177 | #### Custom relationship authorization method 178 | 179 | * `ArticlePolicy.new(current_user, article_1).replace_author?(user_2)` 180 | 181 | #### Fallback 182 | 183 | * `ArticlePolicy.new(current_user, article_1).update?` 184 | * `UserPolicy.new(current_user, user_2).update?` 185 | 186 | **Note:** Currently JA does not fallback to authorizing `UserPolicy#update?` on `user_1` that is about to be dissociated. This will likely be changed in the future. 187 | 188 | 189 | 190 | [back to top ↑](#doc-top) 191 | 192 | ### `PATCH /articles/article-1/` with null `author` relationship 193 | 194 | _Changing resource and removing a `has-one` relationship_ 195 | 196 | Setup: 197 | 198 | ```rb 199 | user_1 = User.create(id: 'user-1') 200 | article_1 = Article.create(id: 'article-1', author: user_1) 201 | ``` 202 | 203 | > `PATCH /articles/article-1` 204 | > 205 | > ```json 206 | > { 207 | > "type": "articles", 208 | > "id": "article-1", 209 | > "relationships": { 210 | > "author": { 211 | > "data": null 212 | > } 213 | > } 214 | > } 215 | > ``` 216 | 217 | #### Always calls 218 | 219 | * `ArticlePolicy.new(current_user, article_1).update?` 220 | 221 | #### Custom relationship authorization method 222 | 223 | * `ArticlePolicy.new(current_user, article_1).remove_author?` 224 | 225 | #### Fallback 226 | 227 | * `ArticlePolicy.new(current_user, article_1).update?` 228 | 229 | **Note:** Currently JA does not fallback to authorizing `UserPolicy#update?` on `user_1` that is about to be dissociated. This will likely be changed in the future. 230 | 231 | 232 | 233 | [back to top ↑](#doc-top) 234 | 235 | ### `POST /articles` with an `author` relationship 236 | 237 | _Creating a resource with a `has-one` relationship_ 238 | 239 | Setup: 240 | 241 | ```rb 242 | user_1 = User.create(id: 'user-1') 243 | ``` 244 | 245 | > `POST /articles` 246 | > 247 | > ```json 248 | > { 249 | > "type": "articles", 250 | > "relationships": { 251 | > "author": { 252 | > "data": { 253 | > "type": "users", 254 | > "id": "user-1" 255 | > } 256 | > } 257 | > } 258 | > } 259 | > ``` 260 | 261 | #### Always calls 262 | 263 | * `ArticlePolicy.new(current_user, Article).create?` 264 | 265 | **Note:** The second parameter for the policy is the `Article` _class_, not the new record. This is because JA runs the authorization checks _before_ any changes are made, even changes to in-memory objects. 266 | 267 | #### Custom relationship authorization method 268 | 269 | * `ArticlePolicy.new(current_user, Article).create_with_author?(user_1)` 270 | 271 | #### Fallback 272 | 273 | * `UserPolicy.new(current_user, user_1).update?` 274 | 275 | 276 | 277 | 278 | [back to top ↑](#doc-top) 279 | 280 | ## `has-many` relationships 281 | 282 | ### Example setup for `has-many` examples 283 | 284 | The examples for `has-many` relationship authorization use these models and resources: 285 | 286 | ```rb 287 | class Article < ActiveRecord::Base 288 | has_many :comments 289 | end 290 | 291 | class ArticleResource < JSONAPI::Resource 292 | include JSONAPI::Authorization::PunditScopedResource 293 | # `acts_as_set` allows replacing all comments at once 294 | has_many :comments, acts_as_set: true 295 | end 296 | ``` 297 | 298 | ```rb 299 | class Comment < ActiveRecord::Base 300 | belongs_to :article 301 | end 302 | 303 | class CommentResource < JSONAPI::Resource 304 | include JSONAPI::Authorization::PunditScopedResource 305 | has_one :article 306 | end 307 | ``` 308 | 309 | 310 | 311 | [back to top ↑](#doc-top) 312 | 313 | ### `POST /articles/article-1/relationships/comments` 314 | 315 | _Adding to a `has-many` relationship_ 316 | 317 | Setup: 318 | 319 | ```rb 320 | comment_1 = Comment.create(id: 'comment-1') 321 | article_1 = Article.create(id: 'article-1', comments: [comment_1]) 322 | comment_2 = Comment.create(id: 'comment-2') 323 | comment_3 = Comment.create(id: 'comment-3') 324 | ``` 325 | 326 | > `POST /articles/article-1/relationships/comments` 327 | > 328 | > ```json 329 | > { 330 | > "data": [ 331 | > { "type": "comments", "id": "comment-2" }, 332 | > { "type": "comments", "id": "comment-3" } 333 | > ] 334 | > } 335 | > ``` 336 | 337 | #### Custom relationship authorization method 338 | 339 | * `ArticlePolicy.new(current_user, article_1).add_to_comments?([comment_2, comment_3])` 340 | 341 | #### Fallback 342 | 343 | * `ArticlePolicy.new(current_user, article_1).update?` 344 | * `CommentPolicy.new(current_user, comment_2).update?` 345 | * `CommentPolicy.new(current_user, comment_3).update?` 346 | 347 | 348 | 349 | [back to top ↑](#doc-top) 350 | 351 | ### `DELETE /articles/article-1/relationships/comments` 352 | 353 | _Removing from a `has-many` relationship_ 354 | 355 | Setup: 356 | 357 | ```rb 358 | comment_1 = Comment.create(id: 'comment-1') 359 | comment_2 = Comment.create(id: 'comment-2') 360 | comment_3 = Comment.create(id: 'comment-3') 361 | article_1 = Article.create(id: 'article-1', comments: [comment_1, comment_2, comment_3]) 362 | ``` 363 | 364 | > `DELETE /articles/article-1/relationships/comments` 365 | > 366 | > ```json 367 | > { 368 | > "data": [ 369 | > { "type": "comments", "id": "comment-1" }, 370 | > { "type": "comments", "id": "comment-2" } 371 | > ] 372 | > } 373 | > ``` 374 | 375 | #### Custom relationship authorization method 376 | 377 | * `ArticlePolicy.new(current_user, article_1).remove_from_comments?([comment_1, comment_2])` 378 | 379 | #### Fallback 380 | 381 | * `ArticlePolicy.new(current_user, article_1).update?` 382 | * `CommentPolicy.new(current_user, comment_1).update?` 383 | * `CommentPolicy.new(current_user, comment_2).update?` 384 | 385 | 386 | 387 | [back to top ↑](#doc-top) 388 | 389 | ### `PATCH /articles/article-1/relationships/comments` with different `comments` 390 | 391 | _Replacing a `has-many` relationship_ 392 | 393 | Setup: 394 | 395 | ```rb 396 | comment_1 = Comment.create(id: 'comment-1') 397 | article_1 = Article.create(id: 'article-1', comments: [comment_1]) 398 | comment_2 = Comment.create(id: 'comment-2') 399 | comment_3 = Comment.create(id: 'comment-3') 400 | ``` 401 | 402 | > `PATCH /articles/article-1/relationships/comments` 403 | > 404 | > ```json 405 | > { 406 | > "data": [ 407 | > { "type": "comments", "id": "comment-2" }, 408 | > { "type": "comments", "id": "comment-3" } 409 | > ] 410 | > } 411 | > ``` 412 | 413 | #### Custom relationship authorization method 414 | 415 | * `ArticlePolicy.new(current_user, article_1).replace_comments?([comment_2, comment_3])` 416 | 417 | #### Fallback 418 | 419 | * `ArticlePolicy.new(current_user, article_1).update?` 420 | * `CommentPolicy.new(current_user, comment_2).update?` 421 | * `CommentPolicy.new(current_user, comment_3).update?` 422 | 423 | **Note:** Currently JA does not fallback to authorizing `CommentPolicy#update?` on `comment_1` that is about to be dissociated. This will likely be changed in the future. 424 | 425 | 426 | 427 | [back to top ↑](#doc-top) 428 | 429 | ### `PATCH /articles/article-1/relationships/comments` with empty `comments` 430 | 431 | _Removing a `has-many` relationship_ 432 | 433 | Setup: 434 | 435 | ```rb 436 | comment_1 = Comment.create(id: 'comment-1') 437 | article_1 = Article.create(id: 'article-1', comments: [comment_1]) 438 | ``` 439 | 440 | > `PATCH /articles/article-1/relationships/comments` 441 | > 442 | > ```json 443 | > { 444 | > "data": [] 445 | > } 446 | > ``` 447 | 448 | #### Custom relationship authorization method 449 | 450 | * `ArticlePolicy.new(current_user, article_1).replace_comments?([])` 451 | 452 | **TODO:** We should probably call `remove_comments?` (with no arguments) instead. See https://github.com/venuu/jsonapi-authorization/issues/73 for more details and implementation progress. 453 | 454 | #### Fallback 455 | 456 | * `ArticlePolicy.new(current_user, article_1).update?` 457 | 458 | **Note:** Currently JA does not fallback to authorizing `CommentPolicy#update?` on `comment_1` that is about to be dissociated. This will likely be changed in the future. 459 | 460 | 461 | 462 | [back to top ↑](#doc-top) 463 | 464 | ### `PATCH /articles/article-1` with different `comments` relationship 465 | 466 | _Changing resource and replacing a `has-many` relationship_ 467 | 468 | Setup: 469 | 470 | ```rb 471 | comment_1 = Comment.create(id: 'comment-1') 472 | article_1 = Article.create(id: 'article-1', comments: [comment_1]) 473 | comment_2 = Comment.create(id: 'comment-2') 474 | comment_3 = Comment.create(id: 'comment-3') 475 | ``` 476 | 477 | > `PATCH /articles/article-1` 478 | > 479 | > ```json 480 | > { 481 | > "type": "articles", 482 | > "id": "article-1", 483 | > "relationships": { 484 | > "comments": { 485 | > "data": [ 486 | > { "type": "comments", "id": "comment-2" }, 487 | > { "type": "comments", "id": "comment-3" } 488 | > ] 489 | > } 490 | > } 491 | > } 492 | > ``` 493 | 494 | #### Always calls 495 | 496 | * `ArticlePolicy.new(current_user, article_1).update?` 497 | 498 | #### Custom relationship authorization method 499 | 500 | * `ArticlePolicy.new(current_user, article_1).replace_comments?([comment_2, comment_3])` 501 | 502 | #### Fallback 503 | 504 | * `ArticlePolicy.new(current_user, article_1).update?` 505 | * `CommentPolicy.new(current_user, comment_2).update?` 506 | * `CommentPolicy.new(current_user, comment_3).update?` 507 | 508 | **Note:** Currently JA does not fallback to authorizing `CommentPolicy#update?` on `comment_1` that is about to be dissociated. This will likely be changed in the future. 509 | 510 | 511 | 512 | [back to top ↑](#doc-top) 513 | 514 | ### `PATCH /articles/article-1` with empty `comments` relationship 515 | 516 | _Changing resource and removing a `has-many` relationship_ 517 | 518 | Setup: 519 | 520 | ```rb 521 | comment_1 = Comment.create(id: 'comment-1') 522 | article_1 = Article.create(id: 'article-1', comments: [comment_1]) 523 | ``` 524 | 525 | > `PATCH /articles/article-1` 526 | > 527 | > ```json 528 | > { 529 | > "type": "articles", 530 | > "id": "article-1", 531 | > "relationships": { 532 | > "comments": { 533 | > "data": [] 534 | > } 535 | > } 536 | > } 537 | > ``` 538 | 539 | #### Always calls 540 | 541 | * `ArticlePolicy.new(current_user, article_1).update?` 542 | 543 | #### Custom relationship authorization method 544 | 545 | * `ArticlePolicy.new(current_user, article_1).replace_comments?([])` 546 | 547 | **TODO:** We should probably call `remove_comments?` (with no arguments) instead. See https://github.com/venuu/jsonapi-authorization/issues/73 for more details and implementation progress. 548 | 549 | #### Fallback 550 | 551 | * `ArticlePolicy.new(current_user, article_1).update?` 552 | 553 | **Note:** Currently JA does not fallback to authorizing `CommentPolicy#update?` on `comment_1` that is about to be dissociated. This will likely be changed in the future. 554 | 555 | 556 | 557 | [back to top ↑](#doc-top) 558 | 559 | ### `POST /articles` with a `comments` relationship 560 | 561 | _Creating a resource with a `has-many` relationship_ 562 | 563 | Setup: 564 | 565 | ```rb 566 | comment_1 = Comment.create(id: 'comment-1') 567 | comment_2 = Comment.create(id: 'comment-2') 568 | ``` 569 | 570 | > `POST /articles` 571 | > 572 | > ```json 573 | > { 574 | > "type": "articles", 575 | > "relationships": { 576 | > "comments": { 577 | > "data": [ 578 | > { "type": "comments", "id": "comment-1" }, 579 | > { "type": "comments", "id": "comment-2" } 580 | > ] 581 | > } 582 | > } 583 | > } 584 | > ``` 585 | 586 | #### Always calls 587 | 588 | * `ArticlePolicy.new(current_user, Article).create?` 589 | 590 | **Note:** The second parameter for the policy is the `Article` _class_, not the new record. This is because JA runs the authorization checks _before_ any changes are made, even changes to in-memory objects. 591 | 592 | #### Custom relationship authorization method 593 | 594 | * `ArticlePolicy.new(current_user, Article).create_with_comments?([comment_1, comment_2])` 595 | 596 | #### Fallback 597 | 598 | * `CommentPolicy.new(current_user, comment_1).update?` 599 | * `CommentPolicy.new(current_user, comment_2).update?` 600 | 601 | [back to top ↑](#doc-top) 602 | -------------------------------------------------------------------------------- /gemfiles/rails_5_2_pundit_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "5.2.4.4" 6 | gem "jsonapi-resources", "~> 0.9.0" 7 | gem "pundit", "~> 1.0" 8 | 9 | group :development, :test do 10 | gem "sqlite3", "~> 1.3.13" 11 | end 12 | 13 | gemspec path: "../" 14 | -------------------------------------------------------------------------------- /gemfiles/rails_5_2_pundit_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "5.2.4.4" 6 | gem "jsonapi-resources", "~> 0.9.0" 7 | gem "pundit", "~> 2.0" 8 | 9 | group :development, :test do 10 | gem "sqlite3", "~> 1.3.13" 11 | end 12 | 13 | gemspec path: "../" 14 | -------------------------------------------------------------------------------- /gemfiles/rails_6_0_pundit_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.0.3.4" 6 | gem "jsonapi-resources", "~> 0.9.0" 7 | gem "pundit", "~> 1.0" 8 | 9 | group :development, :test do 10 | gem "sqlite3", "~> 1.4.1" 11 | end 12 | 13 | gemspec path: "../" 14 | -------------------------------------------------------------------------------- /gemfiles/rails_6_0_pundit_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.0.3.4" 6 | gem "jsonapi-resources", "~> 0.9.0" 7 | gem "pundit", "~> 2.0" 8 | 9 | group :development, :test do 10 | gem "sqlite3", "~> 1.4.1" 11 | end 12 | 13 | gemspec path: "../" 14 | -------------------------------------------------------------------------------- /jsonapi-authorization.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 'jsonapi/authorization/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "jsonapi-authorization" 9 | spec.version = JSONAPI::Authorization::VERSION 10 | spec.authors = ["Vesa Laakso", "Emil Sågfors"] 11 | spec.email = ["laakso.vesa@gmail.com", "emil.sagfors@iki.fi"] 12 | spec.license = "MIT" 13 | 14 | spec.summary = "Generic authorization for jsonapi-resources gem" 15 | spec.description = "Adds generic authorization to the jsonapi-resources gem using Pundit." 16 | spec.homepage = "https://github.com/venuu/jsonapi-authorization" 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "jsonapi-resources", "~> 0.9.0" 22 | spec.add_dependency "pundit", ">= 1.0.0", "< 3.0.0" 23 | 24 | spec.add_development_dependency "appraisal" 25 | spec.add_development_dependency "bundler", ">= 1.11" 26 | spec.add_development_dependency "phare", "~> 1.0.1" 27 | spec.add_development_dependency "pry" 28 | spec.add_development_dependency "pry-byebug" 29 | spec.add_development_dependency "pry-doc" 30 | spec.add_development_dependency "pry-rails" 31 | spec.add_development_dependency "rake", "~> 13.0" 32 | spec.add_development_dependency "rspec", "~> 3.11" 33 | spec.add_development_dependency "rspec-rails", "~> 5.1" 34 | spec.add_development_dependency "rubocop", "~> 1.35.1" 35 | spec.add_development_dependency "sqlite3", "~> 1.3" 36 | spec.metadata['rubygems_mfa_required'] = 'true' 37 | end 38 | -------------------------------------------------------------------------------- /lib/jsonapi-authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jsonapi/authorization' 4 | -------------------------------------------------------------------------------- /lib/jsonapi/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "jsonapi-resources" 4 | require "jsonapi/authorization/authorizing_processor" 5 | require "jsonapi/authorization/configuration" 6 | require "jsonapi/authorization/default_pundit_authorizer" 7 | require "jsonapi/authorization/pundit_scoped_resource" 8 | require "jsonapi/authorization/version" 9 | 10 | module JSONAPI 11 | module Authorization 12 | # Your code goes here... 13 | end 14 | end 15 | 16 | # Allows JSONAPI configuration of operations_processor using the symbol :jsonapi_authorization 17 | JsonapiAuthorizationProcessor = JSONAPI::Authorization::AuthorizingProcessor 18 | -------------------------------------------------------------------------------- /lib/jsonapi/authorization/authorizing_processor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pundit' 4 | 5 | module JSONAPI 6 | module Authorization 7 | class AuthorizingProcessor < JSONAPI::Processor 8 | set_callback :find, :before, :authorize_find 9 | set_callback :show, :before, :authorize_show 10 | set_callback :show_relationship, :before, :authorize_show_relationship 11 | set_callback :show_related_resource, :before, :authorize_show_related_resource 12 | set_callback :show_related_resources, :before, :authorize_show_related_resources 13 | set_callback :create_resource, :before, :authorize_create_resource 14 | set_callback :remove_resource, :before, :authorize_remove_resource 15 | set_callback :replace_fields, :before, :authorize_replace_fields 16 | set_callback :replace_to_one_relationship, :before, :authorize_replace_to_one_relationship 17 | set_callback :remove_to_one_relationship, :before, :authorize_remove_to_one_relationship 18 | set_callback :create_to_many_relationships, :before, :authorize_create_to_many_relationships 19 | set_callback :replace_to_many_relationships, :before, :authorize_replace_to_many_relationships 20 | set_callback :remove_to_many_relationships, :before, :authorize_remove_to_many_relationships 21 | set_callback( 22 | :replace_polymorphic_to_one_relationship, 23 | :before, 24 | :authorize_replace_polymorphic_to_one_relationship 25 | ) 26 | 27 | %i[ 28 | find 29 | show 30 | show_related_resource 31 | show_related_resources 32 | create_resource 33 | replace_fields 34 | ].each do |op_name| 35 | set_callback op_name, :after, :authorize_include_directive 36 | end 37 | 38 | def authorize_include_directive 39 | return if result.is_a?(::JSONAPI::ErrorsOperationResult) 40 | 41 | resources = Array.wrap( 42 | if result.respond_to?(:resources) 43 | result.resources 44 | elsif result.respond_to?(:resource) 45 | result.resource 46 | end 47 | ) 48 | 49 | resources.each do |resource| 50 | authorize_model_includes(resource._model) 51 | end 52 | end 53 | 54 | def authorize_find 55 | authorizer.find(source_class: @resource_klass._model_class) 56 | end 57 | 58 | def authorize_show 59 | record = @resource_klass.find_by_key( 60 | operation_resource_id, 61 | context: context 62 | )._model 63 | 64 | authorizer.show(source_record: record) 65 | end 66 | 67 | def authorize_show_relationship 68 | parent_resource = @resource_klass.find_by_key( 69 | params[:parent_key], 70 | context: context 71 | ) 72 | 73 | relationship = @resource_klass._relationship(params[:relationship_type].to_sym) 74 | 75 | related_resource = 76 | case relationship 77 | when JSONAPI::Relationship::ToOne 78 | parent_resource.public_send(params[:relationship_type].to_sym) 79 | when JSONAPI::Relationship::ToMany 80 | # Do nothing — already covered by policy scopes 81 | else 82 | raise "Unexpected relationship type: #{relationship.inspect}" 83 | end 84 | 85 | parent_record = parent_resource._model 86 | related_record = related_resource._model unless related_resource.nil? 87 | authorizer.show_relationship(source_record: parent_record, related_record: related_record) 88 | end 89 | 90 | def authorize_show_related_resource 91 | source_klass = params[:source_klass] 92 | source_id = params[:source_id] 93 | relationship_type = params[:relationship_type].to_sym 94 | 95 | source_resource = source_klass.find_by_key(source_id, context: context) 96 | 97 | related_resource = source_resource.public_send(relationship_type) 98 | 99 | source_record = source_resource._model 100 | related_record = related_resource._model unless related_resource.nil? 101 | authorizer.show_related_resource( 102 | source_record: source_record, related_record: related_record 103 | ) 104 | end 105 | 106 | def authorize_show_related_resources 107 | source_resource = params[:source_klass].find_by_key( 108 | params[:source_id], 109 | context: context 110 | ) 111 | 112 | source_record = source_resource._model 113 | 114 | authorizer.show_related_resources( 115 | source_record: source_record, related_record_class: @resource_klass._model_class 116 | ) 117 | end 118 | 119 | def authorize_replace_fields 120 | source_record = @resource_klass.find_by_key( 121 | params[:resource_id], 122 | context: context 123 | )._model 124 | authorizer.replace_fields( 125 | source_record: source_record, 126 | related_records_with_context: related_models_with_context 127 | ) 128 | end 129 | 130 | def authorize_create_resource 131 | source_class = resource_klass._model_class 132 | authorizer.create_resource( 133 | source_class: source_class, 134 | related_records_with_context: related_models_with_context 135 | ) 136 | end 137 | 138 | def authorize_remove_resource 139 | record = @resource_klass.find_by_key( 140 | operation_resource_id, 141 | context: context 142 | )._model 143 | 144 | authorizer.remove_resource(source_record: record) 145 | end 146 | 147 | def authorize_replace_to_one_relationship 148 | return authorize_remove_to_one_relationship if params[:key_value].nil? 149 | 150 | source_resource = @resource_klass.find_by_key( 151 | params[:resource_id], 152 | context: context 153 | ) 154 | source_record = source_resource._model 155 | 156 | relationship_type = params[:relationship_type].to_sym 157 | new_related_resource = @resource_klass 158 | ._relationship(relationship_type) 159 | .resource_klass 160 | .find_by_key( 161 | params[:key_value], 162 | context: context 163 | ) 164 | new_related_record = new_related_resource._model unless new_related_resource.nil? 165 | 166 | authorizer.replace_to_one_relationship( 167 | source_record: source_record, 168 | new_related_record: new_related_record, 169 | relationship_type: relationship_type 170 | ) 171 | end 172 | 173 | def authorize_create_to_many_relationships 174 | source_record = @resource_klass.find_by_key( 175 | params[:resource_id], 176 | context: context 177 | )._model 178 | 179 | relationship_type = params[:relationship_type].to_sym 180 | related_models = model_class_for_relationship(relationship_type).find(params[:data]) 181 | 182 | authorizer.create_to_many_relationship( 183 | source_record: source_record, 184 | new_related_records: related_models, 185 | relationship_type: relationship_type 186 | ) 187 | end 188 | 189 | def authorize_replace_to_many_relationships 190 | source_resource = @resource_klass.find_by_key( 191 | params[:resource_id], 192 | context: context 193 | ) 194 | source_record = source_resource._model 195 | 196 | relationship_type = params[:relationship_type].to_sym 197 | new_related_records = model_class_for_relationship(relationship_type).find(params[:data]) 198 | 199 | authorizer.replace_to_many_relationship( 200 | source_record: source_record, 201 | new_related_records: new_related_records, 202 | relationship_type: relationship_type 203 | ) 204 | end 205 | 206 | def authorize_remove_to_many_relationships 207 | source_resource = @resource_klass.find_by_key( 208 | params[:resource_id], 209 | context: context 210 | ) 211 | source_record = source_resource._model 212 | 213 | relationship_type = params[:relationship_type].to_sym 214 | 215 | related_resources = @resource_klass 216 | ._relationship(relationship_type) 217 | .resource_klass 218 | .find_by_keys( 219 | params[:associated_keys], 220 | context: context 221 | ) 222 | 223 | related_records = related_resources.map(&:_model) 224 | 225 | if related_records.size != params[:associated_keys].uniq.size 226 | raise JSONAPI::Exceptions::RecordNotFound, params[:associated_keys] 227 | end 228 | 229 | authorizer.remove_to_many_relationship( 230 | source_record: source_record, 231 | related_records: related_records, 232 | relationship_type: relationship_type 233 | ) 234 | end 235 | 236 | def authorize_remove_to_one_relationship 237 | source_record = @resource_klass.find_by_key( 238 | params[:resource_id], 239 | context: context 240 | )._model 241 | 242 | relationship_type = params[:relationship_type].to_sym 243 | 244 | authorizer.remove_to_one_relationship( 245 | source_record: source_record, relationship_type: relationship_type 246 | ) 247 | end 248 | 249 | def authorize_replace_polymorphic_to_one_relationship 250 | return authorize_remove_to_one_relationship if params[:key_value].nil? 251 | 252 | source_resource = @resource_klass.find_by_key( 253 | params[:resource_id], 254 | context: context 255 | ) 256 | source_record = source_resource._model 257 | 258 | # Fetch the name of the new class based on the incoming polymorphic 259 | # "type" value. This will fail if there is no associated resource for the 260 | # incoming "type" value so this shouldn't leak constants 261 | related_record_class_name = source_resource 262 | .send(:_model_class_name, params[:key_type]) 263 | 264 | # Fetch the underlying Resource class for the new record to-be-associated 265 | related_resource_klass = @resource_klass.resource_for(related_record_class_name) 266 | 267 | new_related_resource = related_resource_klass 268 | .find_by_key( 269 | params[:key_value], 270 | context: context 271 | ) 272 | new_related_record = new_related_resource._model unless new_related_resource.nil? 273 | 274 | relationship_type = params[:relationship_type].to_sym 275 | authorizer.replace_to_one_relationship( 276 | source_record: source_record, 277 | new_related_record: new_related_record, 278 | relationship_type: relationship_type 279 | ) 280 | end 281 | 282 | private 283 | 284 | def authorizer 285 | @authorizer ||= ::JSONAPI::Authorization.configuration.authorizer.new(context: context) 286 | end 287 | 288 | # TODO: Communicate with upstream to fix this nasty hack 289 | def operation_resource_id 290 | case operation_type 291 | when :show 292 | params[:id] 293 | when :show_related_resources 294 | params[:source_id] 295 | else 296 | params[:resource_id] 297 | end 298 | end 299 | 300 | def resource_class_for_relationship(assoc_name) 301 | @resource_klass._relationship(assoc_name).resource_klass 302 | end 303 | 304 | def model_class_for_relationship(assoc_name) 305 | resource_class_for_relationship(assoc_name)._model_class 306 | end 307 | 308 | def related_models_with_context 309 | data = params[:data] 310 | return { relationship: nil, relation_name: nil, records: nil } if data.nil? 311 | 312 | %i[to_one to_many].flat_map do |rel_type| 313 | data[rel_type].flat_map do |assoc_name, assoc_value| 314 | related_models = 315 | case assoc_value 316 | when nil 317 | nil 318 | when Hash # polymorphic relationship 319 | resource_class = @resource_klass.resource_for(assoc_value[:type].to_s) 320 | resource_class.find_by_key(assoc_value[:id], context: context)._model 321 | when Array 322 | resource_class = resource_class_for_relationship(assoc_name) 323 | resources = resource_class.find_by_keys(assoc_value, context: context) 324 | resources.map(&:_model).tap do |scoped_records| 325 | related_ids = Array.wrap(assoc_value).uniq 326 | if scoped_records.count != related_ids.count 327 | raise JSONAPI::Exceptions::RecordNotFound, related_ids 328 | end 329 | end 330 | else 331 | resource_class = resource_class_for_relationship(assoc_name) 332 | resource_class.find_by_key(assoc_value, context: context)._model 333 | end 334 | 335 | { 336 | relation_type: rel_type, 337 | relation_name: assoc_name, 338 | records: related_models 339 | } 340 | end 341 | end 342 | end 343 | 344 | def authorize_model_includes(source_record) 345 | return unless params[:include_directives] 346 | 347 | params[:include_directives].model_includes.each do |include_item| 348 | authorize_include_item(@resource_klass, source_record, include_item) 349 | end 350 | end 351 | 352 | def authorize_include_item(resource_klass, source_record, include_item) 353 | case include_item 354 | when Hash 355 | # e.g. {articles: [:comments, :author]} when ?include=articles.comments,articles.author 356 | include_item.each do |rel_name, deep| 357 | authorize_include_item(resource_klass, source_record, rel_name) 358 | relationship = resource_klass._relationship(rel_name) 359 | next_resource_klass = relationship.resource_klass 360 | Array.wrap( 361 | source_record.public_send( 362 | relationship.relation_name(context: context) 363 | ) 364 | ).each do |next_source_record| 365 | deep.each do |next_include_item| 366 | authorize_include_item( 367 | next_resource_klass, 368 | next_source_record, 369 | next_include_item 370 | ) 371 | end 372 | end 373 | end 374 | when Symbol 375 | relationship = resource_klass._relationship(include_item) 376 | case relationship 377 | when JSONAPI::Relationship::ToOne 378 | related_record = source_record.public_send( 379 | relationship.relation_name(context: context) 380 | ) 381 | return if related_record.nil? 382 | 383 | authorizer.include_has_one_resource( 384 | source_record: source_record, related_record: related_record 385 | ) 386 | when JSONAPI::Relationship::ToMany 387 | authorizer.include_has_many_resource( 388 | source_record: source_record, 389 | record_class: relationship.resource_klass._model_class 390 | ) 391 | else 392 | raise "Unexpected relationship type: #{relationship.inspect}" 393 | end 394 | else 395 | raise "Unknown include directive: #{include_item}" 396 | end 397 | end 398 | end 399 | end 400 | end 401 | -------------------------------------------------------------------------------- /lib/jsonapi/authorization/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jsonapi/authorization/default_pundit_authorizer' 4 | 5 | module JSONAPI 6 | module Authorization 7 | class Configuration 8 | attr_accessor :authorizer, :pundit_user 9 | 10 | def initialize 11 | self.authorizer = ::JSONAPI::Authorization::DefaultPunditAuthorizer 12 | self.pundit_user = :user 13 | end 14 | 15 | def user_context(context) 16 | if pundit_user.is_a?(Symbol) 17 | context[pundit_user] 18 | else 19 | pundit_user.call(context) 20 | end 21 | end 22 | end 23 | 24 | class << self 25 | attr_accessor :configuration 26 | end 27 | 28 | @configuration ||= Configuration.new 29 | 30 | def self.configure 31 | yield(@configuration) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/jsonapi/authorization/default_pundit_authorizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | module Authorization 5 | # An authorizer is a class responsible for linking JSONAPI operations to 6 | # your choice of authorization mechanism. 7 | # 8 | # This class uses Pundit for authorization. You can use your own authorizer 9 | # class instead if you have different needs. See the README.md for 10 | # configuration information. 11 | # 12 | # Fetching records is the concern of +PunditScopedResource+ which in turn 13 | # affects which records end up being passed here. 14 | class DefaultPunditAuthorizer 15 | attr_reader :user 16 | 17 | # Creates a new DefaultPunditAuthorizer instance 18 | # 19 | # ==== Parameters 20 | # 21 | # * +context+ - The context passed down from the controller layer 22 | def initialize(context:) 23 | @user = JSONAPI::Authorization.configuration.user_context(context) 24 | end 25 | 26 | # GET /resources 27 | # 28 | # ==== Parameters 29 | # 30 | # * +source_class+ - The source class (e.g. +Article+ for +ArticleResource+) 31 | def find(source_class:) 32 | ::Pundit.authorize(user, source_class, 'index?') 33 | end 34 | 35 | # GET /resources/:id 36 | # 37 | # ==== Parameters 38 | # 39 | # * +source_record+ - The record to show 40 | def show(source_record:) 41 | ::Pundit.authorize(user, source_record, 'show?') 42 | end 43 | 44 | # GET /resources/:id/relationships/other-resources 45 | # GET /resources/:id/relationships/another-resource 46 | # 47 | # A query for a +has_one+ or a +has_many+ association 48 | # 49 | # ==== Parameters 50 | # 51 | # * +source_record+ - The record whose relationship is queried 52 | # * +related_record+ - The associated +has_one+ record to show or +nil+ 53 | # if the associated record was not found. For a +has_many+ association, 54 | # this will always be +nil+ 55 | def show_relationship(source_record:, related_record:) 56 | ::Pundit.authorize(user, source_record, 'show?') 57 | ::Pundit.authorize(user, related_record, 'show?') unless related_record.nil? 58 | end 59 | 60 | # GET /resources/:id/another-resource 61 | # 62 | # A query for a record through a +has_one+ association 63 | # 64 | # ==== Parameters 65 | # 66 | # * +source_record+ - The record whose relationship is queried 67 | # * +related_record+ - The associated record to show or +nil+ if the 68 | # associated record was not found 69 | def show_related_resource(source_record:, related_record:) 70 | ::Pundit.authorize(user, source_record, 'show?') 71 | ::Pundit.authorize(user, related_record, 'show?') unless related_record.nil? 72 | end 73 | 74 | # GET /resources/:id/other-resources 75 | # 76 | # A query for records through a +has_many+ association 77 | # 78 | # ==== Parameters 79 | # 80 | # * +source_record+ - The record whose relationship is queried 81 | # * +related_record_class+ - The associated record class to show 82 | def show_related_resources(source_record:, related_record_class:) 83 | ::Pundit.authorize(user, source_record, 'show?') 84 | ::Pundit.authorize(user, related_record_class, 'index?') 85 | end 86 | 87 | # PATCH /resources/:id 88 | # 89 | # ==== Parameters 90 | # 91 | # * +source_record+ - The record to be modified 92 | # * +related_records_with_context+ - A hash with the association type, 93 | # the relationship name, an Array of new related records. 94 | def replace_fields(source_record:, related_records_with_context:) 95 | ::Pundit.authorize(user, source_record, 'update?') 96 | authorize_related_records( 97 | source_record: source_record, 98 | related_records_with_context: related_records_with_context 99 | ) 100 | end 101 | 102 | # POST /resources 103 | # 104 | # ==== Parameters 105 | # 106 | # * +source_class+ - The class of the record to be created 107 | # * +related_records_with_context+ - A has with the association type, 108 | # the relationship name, and an Array of new related records. 109 | def create_resource(source_class:, related_records_with_context:) 110 | ::Pundit.authorize(user, source_class, 'create?') 111 | related_records_with_context.each do |data| 112 | relation_name = data[:relation_name] 113 | records = data[:records] 114 | relationship_method = "create_with_#{relation_name}?" 115 | policy = ::Pundit.policy(user, source_class) 116 | if policy.respond_to?(relationship_method) 117 | unless policy.public_send(relationship_method, records) 118 | raise ::Pundit::NotAuthorizedError, 119 | query: relationship_method, 120 | record: source_class, 121 | policy: policy 122 | end 123 | else 124 | Array(records).each do |record| 125 | ::Pundit.authorize(user, record, 'update?') 126 | end 127 | end 128 | end 129 | end 130 | 131 | # DELETE /resources/:id 132 | # 133 | # ==== Parameters 134 | # 135 | # * +source_record+ - The record to be removed 136 | def remove_resource(source_record:) 137 | ::Pundit.authorize(user, source_record, 'destroy?') 138 | end 139 | 140 | # PATCH /resources/:id/relationships/another-resource 141 | # 142 | # A replace request for a +has_one+ association 143 | # 144 | # ==== Parameters 145 | # 146 | # * +source_record+ - The record whose relationship is modified 147 | # * +new_related_record+ - The new record replacing the old record 148 | # * +relationship_type+ - The relationship type 149 | def replace_to_one_relationship(source_record:, new_related_record:, relationship_type:) 150 | relationship_method = "replace_#{relationship_type}?" 151 | authorize_relationship_operation( 152 | source_record: source_record, 153 | relationship_method: relationship_method, 154 | related_record_or_records: new_related_record 155 | ) 156 | end 157 | 158 | # POST /resources/:id/relationships/other-resources 159 | # 160 | # A request for adding to a +has_many+ association 161 | # 162 | # ==== Parameters 163 | # 164 | # * +source_record+ - The record whose relationship is modified 165 | # * +new_related_records+ - The new records to be added to the association 166 | # * +relationship_type+ - The relationship type 167 | def create_to_many_relationship(source_record:, new_related_records:, relationship_type:) 168 | relationship_method = "add_to_#{relationship_type}?" 169 | authorize_relationship_operation( 170 | source_record: source_record, 171 | relationship_method: relationship_method, 172 | related_record_or_records: new_related_records 173 | ) 174 | end 175 | 176 | # PATCH /resources/:id/relationships/other-resources 177 | # 178 | # A replace request for a +has_many+ association 179 | # 180 | # ==== Parameters 181 | # 182 | # * +source_record+ - The record whose relationship is modified 183 | # * +new_related_records+ - The new records replacing the entire +has_many+ 184 | # association 185 | # * +relationship_type+ - The relationship type 186 | def replace_to_many_relationship(source_record:, new_related_records:, relationship_type:) 187 | relationship_method = "replace_#{relationship_type}?" 188 | authorize_relationship_operation( 189 | source_record: source_record, 190 | relationship_method: relationship_method, 191 | related_record_or_records: new_related_records 192 | ) 193 | end 194 | 195 | # DELETE /resources/:id/relationships/other-resources 196 | # 197 | # A request to disassociate elements of a +has_many+ association 198 | # 199 | # ==== Parameters 200 | # 201 | # * +source_record+ - The record whose relationship is modified 202 | # * +related_records+ - The records which will be disassociated from +source_record+ 203 | # * +relationship_type+ - The relationship type 204 | def remove_to_many_relationship(source_record:, related_records:, relationship_type:) 205 | relationship_method = "remove_from_#{relationship_type}?" 206 | authorize_relationship_operation( 207 | source_record: source_record, 208 | relationship_method: relationship_method, 209 | related_record_or_records: related_records 210 | ) 211 | end 212 | 213 | # DELETE /resources/:id/relationships/another-resource 214 | # 215 | # A request to disassociate a +has_one+ association 216 | # 217 | # ==== Parameters 218 | # 219 | # * +source_record+ - The record whose relationship is modified 220 | # * +relationship_type+ - The relationship type 221 | def remove_to_one_relationship(source_record:, relationship_type:) 222 | relationship_method = "remove_#{relationship_type}?" 223 | authorize_relationship_operation( 224 | source_record: source_record, 225 | relationship_method: relationship_method 226 | ) 227 | end 228 | 229 | # Any request including ?include=other-resources 230 | # 231 | # This will be called for each has_many relationship if the include goes 232 | # deeper than one level until some authorization fails or the include 233 | # directive has been travelled completely. 234 | # 235 | # We can't pass all the records of a +has_many+ association here due to 236 | # performance reasons, so the class is passed instead. 237 | # 238 | # ==== Parameters 239 | # 240 | # * +source_record+ — The source relationship record, e.g. an Article in 241 | # article.comments check 242 | # * +record_class+ - The underlying record class for the relationships 243 | # resource. 244 | # rubocop:disable Lint/UnusedMethodArgument 245 | def include_has_many_resource(source_record:, record_class:) 246 | ::Pundit.authorize(user, record_class, 'index?') 247 | end 248 | # rubocop:enable Lint/UnusedMethodArgument 249 | 250 | # Any request including ?include=another-resource 251 | # 252 | # This will be called for each has_one relationship if the include goes 253 | # deeper than one level until some authorization fails or the include 254 | # directive has been travelled completely. 255 | # 256 | # ==== Parameters 257 | # 258 | # * +source_record+ — The source relationship record, e.g. an Article in 259 | # article.author check 260 | # * +related_record+ - The associated record to return 261 | # rubocop:disable Lint/UnusedMethodArgument 262 | def include_has_one_resource(source_record:, related_record:) 263 | ::Pundit.authorize(user, related_record, 'show?') 264 | end 265 | # rubocop:enable Lint/UnusedMethodArgument 266 | 267 | private 268 | 269 | def authorize_relationship_operation( 270 | source_record:, 271 | relationship_method:, 272 | related_record_or_records: nil 273 | ) 274 | policy = ::Pundit.policy(user, source_record) 275 | if policy.respond_to?(relationship_method) 276 | args = [relationship_method, related_record_or_records].compact 277 | unless policy.public_send(*args) 278 | raise ::Pundit::NotAuthorizedError, 279 | query: relationship_method, 280 | record: source_record, 281 | policy: policy 282 | end 283 | else 284 | ::Pundit.authorize(user, source_record, 'update?') 285 | if related_record_or_records 286 | Array(related_record_or_records).each do |related_record| 287 | ::Pundit.authorize(user, related_record, 'update?') 288 | end 289 | end 290 | end 291 | end 292 | 293 | def authorize_related_records(source_record:, related_records_with_context:) 294 | related_records_with_context.each do |data| 295 | relation_type = data[:relation_type] 296 | relation_name = data[:relation_name] 297 | records = data[:records] 298 | case relation_type 299 | when :to_many 300 | replace_to_many_relationship( 301 | source_record: source_record, 302 | new_related_records: records, 303 | relationship_type: relation_name 304 | ) 305 | when :to_one 306 | if records.nil? 307 | remove_to_one_relationship( 308 | source_record: source_record, 309 | relationship_type: relation_name 310 | ) 311 | else 312 | replace_to_one_relationship( 313 | source_record: source_record, 314 | new_related_record: records, 315 | relationship_type: relation_name 316 | ) 317 | end 318 | end 319 | end 320 | end 321 | end 322 | end 323 | end 324 | -------------------------------------------------------------------------------- /lib/jsonapi/authorization/pundit_scoped_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pundit' 4 | 5 | module JSONAPI 6 | module Authorization 7 | module PunditScopedResource 8 | extend ActiveSupport::Concern 9 | 10 | module ClassMethods 11 | def records(options = {}) 12 | user_context = JSONAPI::Authorization.configuration.user_context(options[:context]) 13 | ::Pundit.policy_scope!(user_context, _model_class) 14 | end 15 | end 16 | 17 | def records_for(association_name) 18 | record_or_records = @model.public_send(association_name) 19 | relationship = fetch_relationship(association_name) 20 | 21 | case relationship 22 | when JSONAPI::Relationship::ToOne 23 | record_or_records 24 | when JSONAPI::Relationship::ToMany 25 | user_context = JSONAPI::Authorization.configuration.user_context(context) 26 | ::Pundit.policy_scope!(user_context, record_or_records) 27 | else 28 | raise "Unknown relationship type #{relationship.inspect}" 29 | end 30 | end 31 | 32 | private 33 | 34 | def fetch_relationship(association_name) 35 | relationships = self.class._relationships.select do |_k, v| 36 | v.relation_name(context: context) == association_name 37 | end 38 | if relationships.empty? 39 | nil 40 | else 41 | relationships.values.first 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/jsonapi/authorization/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | module Authorization 5 | VERSION = "3.0.2" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.sqlite3 3 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('config/application', __dir__) 4 | 5 | Rails.application.load_tasks 6 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/venuu/jsonapi-authorization/3184da84e3d83ae2bef4e472232680efa6e5b52c/spec/dummy/app/assets/config/manifest.js -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/articles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ArticlesController < ApplicationController 4 | include JSONAPI::ActsAsResourceController 5 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 6 | 7 | private 8 | 9 | def context 10 | { user: nil } 11 | end 12 | 13 | # https://github.com/cerebris/jsonapi-resources/pull/573 14 | def handle_exceptions(err) 15 | if JSONAPI.configuration.exception_class_whitelist.any? { |k| err.class.ancestors.include?(k) } 16 | raise err 17 | end 18 | 19 | super 20 | end 21 | 22 | def user_not_authorized 23 | head :forbidden 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/comments_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentsController < ApplicationController 4 | include JSONAPI::ActsAsResourceController 5 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 6 | 7 | private 8 | 9 | def context 10 | { user: nil } 11 | end 12 | 13 | # https://github.com/cerebris/jsonapi-resources/pull/573 14 | def handle_exceptions(err) 15 | if JSONAPI.configuration.exception_class_whitelist.any? { |k| err.class.ancestors.include?(k) } 16 | raise err 17 | end 18 | 19 | super 20 | end 21 | 22 | def user_not_authorized 23 | head :forbidden 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/taggable_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # http://jsonapi-resources.com/v0.9/guide/resources.html#Relationships 4 | # 5 | # > The polymorphic relationship will require the resource 6 | # > and controller to exist, although routing to them will 7 | # > cause an error. 8 | class TaggablesController < JSONAPI::ResourceController; end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/tags_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TagsController < ApplicationController 4 | include JSONAPI::ActsAsResourceController 5 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 6 | 7 | private 8 | 9 | def context 10 | { user: nil } 11 | end 12 | 13 | # https://github.com/cerebris/jsonapi-resources/pull/573 14 | def handle_exceptions(err) 15 | if JSONAPI.configuration.exception_class_whitelist.any? { |k| err.class.ancestors.include?(k) } 16 | raise err 17 | end 18 | 19 | super 20 | end 21 | 22 | def user_not_authorized 23 | head :forbidden 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UsersController < ApplicationController 4 | include JSONAPI::ActsAsResourceController 5 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 6 | 7 | private 8 | 9 | def context 10 | { user: nil } 11 | end 12 | 13 | # https://github.com/cerebris/jsonapi-resources/pull/573 14 | def handle_exceptions(err) 15 | if JSONAPI.configuration.exception_class_whitelist.any? { |k| err.class.ancestors.include?(k) } 16 | raise err 17 | end 18 | 19 | super 20 | end 21 | 22 | def user_not_authorized 23 | head :forbidden 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/app/models/article.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Article < ActiveRecord::Base 4 | has_many :comments 5 | has_many :tags, as: :taggable 6 | belongs_to :author, class_name: 'User' 7 | 8 | def to_param 9 | external_id 10 | end 11 | 12 | # Hack for easy include directive checks 13 | has_many :articles, -> { limit(2) }, foreign_key: :id 14 | has_one :article, foreign_key: :id 15 | has_one :non_existing_article, -> { none }, class_name: 'Article', foreign_key: :id 16 | has_many :empty_articles, -> { none }, class_name: 'Article', foreign_key: :id 17 | 18 | # Setting blank_value attribute is an easy way to test that authorizations 19 | # work even when the model has validation errors 20 | validate :blank_value_must_be_blank 21 | 22 | private 23 | 24 | def blank_value_must_be_blank 25 | errors.add(:blank_value, 'must be blank') unless blank_value.blank? 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/dummy/app/models/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Comment < ActiveRecord::Base 4 | has_many :tags, as: :taggable 5 | belongs_to :article 6 | belongs_to :author, class_name: 'User' 7 | belongs_to :reviewing_user, class_name: 'User' 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/models/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tag < ActiveRecord::Base 4 | belongs_to :taggable, polymorphic: true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ActiveRecord::Base 4 | has_many :articles, foreign_key: :author_id 5 | has_many :comments, foreign_key: :author_id 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/policies/article_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ArticlePolicy 4 | Scope = Struct.new(:user, :scope) do 5 | def resolve 6 | raise NotImplementedError 7 | end 8 | end 9 | 10 | attr_reader :user, :record 11 | 12 | def initialize(user, record) 13 | @user = user 14 | @record = record 15 | end 16 | 17 | def index? 18 | raise NotImplementedError 19 | end 20 | 21 | def show? 22 | raise NotImplementedError 23 | end 24 | 25 | def create? 26 | raise NotImplementedError 27 | end 28 | 29 | def update? 30 | raise NotImplementedError 31 | end 32 | 33 | def destroy? 34 | raise NotImplementedError 35 | end 36 | 37 | def create_with_author?(_author) 38 | raise NotImplementedError 39 | end 40 | 41 | def create_with_comments?(_comments) 42 | raise NotImplementedError 43 | end 44 | 45 | def add_to_comments?(_comments) 46 | raise NotImplementedError 47 | end 48 | 49 | def replace_comments?(_comments) 50 | raise NotImplementedError 51 | end 52 | 53 | def remove_from_comments?(_comment) 54 | raise NotImplementedError 55 | end 56 | 57 | def replace_author?(_author) 58 | raise NotImplementedError 59 | end 60 | 61 | def remove_author? 62 | raise NotImplementedError 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/dummy/app/policies/comment_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentPolicy 4 | Scope = Struct.new(:user, :scope) do 5 | def resolve 6 | raise NotImplementedError 7 | end 8 | end 9 | 10 | attr_reader :user, :record 11 | 12 | def initialize(user, record) 13 | @user = user 14 | @record = record 15 | end 16 | 17 | def index? 18 | raise NotImplementedError 19 | end 20 | 21 | def show? 22 | raise NotImplementedError 23 | end 24 | 25 | def create? 26 | raise NotImplementedError 27 | end 28 | 29 | def update? 30 | raise NotImplementedError 31 | end 32 | 33 | def destroy? 34 | raise NotImplementedError 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/dummy/app/policies/tag_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TagPolicy 4 | Scope = Struct.new(:user, :scope) do 5 | def resolve 6 | raise NotImplementedError 7 | end 8 | end 9 | 10 | attr_reader :user, :record 11 | 12 | def initialize(user, record) 13 | @user = user 14 | @record = record 15 | end 16 | 17 | def index? 18 | raise NotImplementedError 19 | end 20 | 21 | def show? 22 | raise NotImplementedError 23 | end 24 | 25 | def create? 26 | raise NotImplementedError 27 | end 28 | 29 | def update? 30 | raise NotImplementedError 31 | end 32 | 33 | def destroy? 34 | raise NotImplementedError 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/dummy/app/policies/user_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserPolicy 4 | Scope = Struct.new(:user, :scope) do 5 | def resolve 6 | raise NotImplementedError 7 | end 8 | end 9 | 10 | attr_reader :user, :record 11 | 12 | def initialize(user, record) 13 | @user = user 14 | @record = record 15 | end 16 | 17 | def index? 18 | raise NotImplementedError 19 | end 20 | 21 | def show? 22 | raise NotImplementedError 23 | end 24 | 25 | def create? 26 | raise NotImplementedError 27 | end 28 | 29 | def update? 30 | raise NotImplementedError 31 | end 32 | 33 | def destroy? 34 | raise NotImplementedError 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/dummy/app/resources/article_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ArticleResource < JSONAPI::Resource 4 | include JSONAPI::Authorization::PunditScopedResource 5 | 6 | has_many :comments, acts_as_set: true 7 | has_many :tags 8 | has_one :author, class_name: 'User' 9 | 10 | primary_key :external_id 11 | 12 | def self.verify_key(key, _context = nil) 13 | key && String(key) 14 | end 15 | 16 | def id=(external_id) 17 | _model.external_id = external_id 18 | end 19 | 20 | # # Hack for easy include directive checks 21 | has_many :articles 22 | has_one :article 23 | has_one :non_existing_article, class_name: 'Article', foreign_key_on: :related 24 | has_many :empty_articles, class_name: 'Article', foreign_key_on: :related 25 | 26 | # Setting this attribute is an easy way to test that authorizations work even 27 | # when the model has validation errors 28 | attributes :blank_value 29 | 30 | def self.creatable_fields(context) 31 | super + [:id] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy/app/resources/comment_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentResource < JSONAPI::Resource 4 | include JSONAPI::Authorization::PunditScopedResource 5 | 6 | has_many :tags 7 | has_one :article 8 | has_one :reviewer, relation_name: "reviewing_user", class_name: "User" 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/app/resources/tag_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TagResource < JSONAPI::Resource 4 | include JSONAPI::Authorization::PunditScopedResource 5 | 6 | has_one :taggable, polymorphic: true 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/resources/taggable_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # http://jsonapi-resources.com/v0.9/guide/resources.html#Relationships 4 | # 5 | # > The polymorphic relationship will require the resource 6 | # > and controller to exist, although routing to them will 7 | # > cause an error. 8 | class TaggableResource < JSONAPI::Resource 9 | def self.verify_key(key, _context = nil) 10 | # Allow a string key for polymorphic associations 11 | key && String(key) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/app/resources/user_resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserResource < JSONAPI::Resource 4 | include JSONAPI::Authorization::PunditScopedResource 5 | 6 | has_many :comments 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_PATH = File.expand_path('../config/application', __dir__) 5 | require_relative '../config/boot' 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative '../config/boot' 5 | require 'rake' 6 | Rake.application.run 7 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require ::File.expand_path('config/environment', __dir__) 6 | run Rails.application 7 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('boot', __dir__) 4 | 5 | require "rails/all" 6 | Bundler.require(:default, Rails.env) 7 | 8 | class Application < Rails::Application 9 | config.root = File.expand_path('..', __dir__) 10 | config.cache_classes = true 11 | 12 | config.eager_load = false 13 | config.serve_static_files = true 14 | config.static_cache_control = "public, max-age=3600" 15 | 16 | config.consider_all_requests_local = true 17 | config.action_controller.perform_caching = false 18 | 19 | config.action_dispatch.show_exceptions = false 20 | 21 | config.action_controller.allow_forgery_protection = false 22 | 23 | config.active_support.deprecation = :stderr 24 | 25 | config.middleware.delete "Rack::Lock" 26 | config.middleware.delete "ActionDispatch::Flash" 27 | # config.middleware.delete "ActionDispatch::BestStandardsSupport" 28 | 29 | config.secret_key_base = "correct-horse-battery-staple" 30 | end 31 | 32 | JSONAPI.configure do |config| 33 | config.default_processor_klass = JSONAPI::Authorization::AuthorizingProcessor 34 | config.exception_class_whitelist = [Pundit::NotAuthorizedError] 35 | end 36 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 5 | 6 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 7 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 8 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | pool: 5 4 | timeout: 5000 5 | database: db/test.sqlite3 6 | 7 | development: 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | database: db/development.sqlite3 12 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require File.expand_path('application', __dir__) 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | jsonapi_resources :articles do 5 | jsonapi_relationships 6 | end 7 | jsonapi_resources :comments do 8 | jsonapi_relationships 9 | end 10 | jsonapi_resources :tags 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20160125083537_create_models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateModels < ActiveRecord::Migration 4 | def change 5 | create_table :comments do |t| 6 | t.string :article_id 7 | t.belongs_to :author 8 | t.belongs_to :reviewing_user, references: :user 9 | end 10 | 11 | create_table :users 12 | 13 | create_table :articles do |t| 14 | t.string :external_id, null: false 15 | t.references :author 16 | t.string :blank_value 17 | end 18 | 19 | create_table :tags do |t| 20 | t.references :taggable, polymorphic: true 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20160125083537) do 15 | 16 | create_table "articles", force: :cascade do |t| 17 | t.string "external_id", null: false 18 | t.integer "author_id" 19 | t.string "blank_value" 20 | end 21 | 22 | create_table "comments", force: :cascade do |t| 23 | t.string "article_id" 24 | t.integer "author_id" 25 | t.integer "reviewing_user_id" 26 | end 27 | 28 | create_table "tags", force: :cascade do |t| 29 | t.integer "taggable_id" 30 | t.string "taggable_type" 31 | end 32 | 33 | create_table "users", force: :cascade do |t| 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/venuu/jsonapi-authorization/3184da84e3d83ae2bef4e472232680efa6e5b52c/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/fixtures/articles.yml: -------------------------------------------------------------------------------- 1 | --- 2 | article_with_comments: 3 | external_id: 1 4 | article_without_comments: 5 | external_id: 2 6 | article_with_author: 7 | external_id: 3 8 | author: user_with_articles 9 | article_without_author: 10 | external_id: 4 11 | author: ~ 12 | -------------------------------------------------------------------------------- /spec/fixtures/comments.yml: -------------------------------------------------------------------------------- 1 | --- 2 | comment_1: 3 | article: article_with_comments 4 | author: user_with_comments 5 | reviewing_user: reviewer 6 | comment_2: 7 | article: article_with_comments 8 | author: user_with_comments 9 | reviewing_user: reviewer 10 | comment_3: 11 | article: article_with_comments 12 | author: user_with_comments 13 | reviewing_user: reviewer 14 | -------------------------------------------------------------------------------- /spec/fixtures/tags.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tag_1: 3 | taggable: comment_1 4 | tag_2: 5 | taggable: comment_1 6 | tag_3: 7 | taggable: article_with_comments 8 | tag_4: 9 | taggable: article_with_comments 10 | -------------------------------------------------------------------------------- /spec/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | --- 2 | user_with_articles: {} 3 | user_with_comments: {} 4 | reviewer: {} 5 | -------------------------------------------------------------------------------- /spec/jsonapi/authorization/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | RSpec.describe JSONAPI::Authorization::Configuration do 5 | after do 6 | # Set this back to the default after each 7 | JSONAPI::Authorization.configuration.pundit_user = :user 8 | end 9 | 10 | describe '#user_context' do 11 | context "given a symbol" do 12 | it "returns the 'user'" do 13 | JSONAPI::Authorization.configuration.pundit_user = :current_user 14 | 15 | user = User.new 16 | jsonapi_context = { current_user: user } 17 | user_context = JSONAPI::Authorization.configuration.user_context(jsonapi_context) 18 | 19 | expect(user_context).to be user 20 | end 21 | end 22 | 23 | context "given a proc" do 24 | it "returns the 'user'" do 25 | JSONAPI::Authorization.configuration.pundit_user = ->(context) { context[:current_user] } 26 | 27 | user = User.new 28 | jsonapi_context = { current_user: user } 29 | user_context = JSONAPI::Authorization.configuration.user_context(jsonapi_context) 30 | 31 | expect(user_context).to be user 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/jsonapi/authorization/default_pundit_authorizer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe JSONAPI::Authorization::DefaultPunditAuthorizer do 6 | include PunditStubs 7 | fixtures :all 8 | 9 | let(:source_record) { Article.new } 10 | let(:authorizer) { described_class.new(context: {}) } 11 | 12 | shared_examples_for :update_singular_fallback do |related_record_method| 13 | context 'authorized for update? on related record' do 14 | before { stub_policy_actions(send(related_record_method), update?: true) } 15 | 16 | it 'does not raise any errors' do 17 | expect { subject }.not_to raise_error 18 | end 19 | end 20 | 21 | context 'unauthorized for update? on related record' do 22 | before { stub_policy_actions(send(related_record_method), update?: false) } 23 | 24 | it 'raises a NotAuthorizedError' do 25 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 26 | end 27 | end 28 | end 29 | 30 | shared_examples_for :update_multiple_fallback do |related_records_method| 31 | context 'authorized for update? on all related records' do 32 | before do 33 | send(related_records_method).each { |r| stub_policy_actions(r, update?: true) } 34 | end 35 | 36 | it 'does not raise any errors' do 37 | expect { subject }.not_to raise_error 38 | end 39 | end 40 | 41 | context 'unauthorized for update? on any related records' do 42 | before do 43 | stub_policy_actions(send(related_records_method).first, update?: false) 44 | end 45 | 46 | it 'raises a NotAuthorizedError' do 47 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 48 | end 49 | end 50 | end 51 | 52 | describe '#find' do 53 | subject(:method_call) do 54 | authorizer.find(source_class: source_record) 55 | end 56 | 57 | context 'authorized for index? on record' do 58 | before { allow_action(source_record, 'index?') } 59 | it 'does not raise any errors' do 60 | expect { subject }.not_to raise_error 61 | end 62 | end 63 | 64 | context 'unauthorized for index? on record' do 65 | before { disallow_action(source_record, 'index?') } 66 | it 'raises a NotAuthorizedError' do 67 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 68 | end 69 | end 70 | end 71 | 72 | describe '#show' do 73 | subject(:method_call) do 74 | authorizer.show(source_record: source_record) 75 | end 76 | 77 | context 'authorized for show? on record' do 78 | before { allow_action(source_record, 'show?') } 79 | it 'does not raise any errors' do 80 | expect { subject }.not_to raise_error 81 | end 82 | end 83 | 84 | context 'unauthorized for show? on record' do 85 | before { disallow_action(source_record, 'show?') } 86 | it 'raises a NotAuthorizedError' do 87 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 88 | end 89 | end 90 | end 91 | 92 | describe '#show_relationship' do 93 | subject(:method_call) do 94 | authorizer.show_relationship( 95 | source_record: source_record, related_record: related_record 96 | ) 97 | end 98 | 99 | context 'authorized for show? on source record' do 100 | before { allow_action(source_record, 'show?') } 101 | 102 | context 'related record is present' do 103 | let(:related_record) { Comment.new } 104 | 105 | context 'authorized for show on related record' do 106 | before { allow_action(related_record, 'show?') } 107 | it 'does not raise any errors' do 108 | expect { subject }.not_to raise_error 109 | end 110 | end 111 | 112 | context 'unauthorized for show on related record' do 113 | before { disallow_action(related_record, 'show?') } 114 | it 'raises a NotAuthorizedError' do 115 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 116 | end 117 | end 118 | end 119 | 120 | context 'related record is nil' do 121 | let(:related_record) { nil } 122 | it 'does not raise any errors' do 123 | expect { subject }.not_to raise_error 124 | end 125 | end 126 | end 127 | 128 | context 'unauthorized for show? on source record' do 129 | before { disallow_action(source_record, 'show?') } 130 | 131 | context 'related record is present' do 132 | let(:related_record) { Comment.new } 133 | 134 | context 'authorized for show on related record' do 135 | before { allow_action(related_record, 'show?') } 136 | it 'raises a NotAuthorizedError' do 137 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 138 | end 139 | end 140 | 141 | context 'unauthorized for show on related record' do 142 | before { disallow_action(related_record, 'show?') } 143 | it 'raises a NotAuthorizedError' do 144 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 145 | end 146 | end 147 | end 148 | 149 | context 'related record is nil' do 150 | let(:related_record) { nil } 151 | it 'raises a NotAuthorizedError' do 152 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 153 | end 154 | end 155 | end 156 | end 157 | 158 | describe '#show_related_resource' do 159 | subject(:method_call) do 160 | authorizer.show_related_resource( 161 | source_record: source_record, 162 | related_record: related_record 163 | ) 164 | end 165 | 166 | context 'authorized for show? on source record' do 167 | before { allow_action(source_record, 'show?') } 168 | 169 | context 'related record is present' do 170 | let(:related_record) { Comment.new } 171 | 172 | context 'authorized for show on related record' do 173 | before { allow_action(related_record, 'show?') } 174 | it 'does not raise any errors' do 175 | expect { subject }.not_to raise_error 176 | end 177 | end 178 | 179 | context 'unauthorized for show on related record' do 180 | before { disallow_action(related_record, 'show?') } 181 | it 'raises a NotAuthorizedError' do 182 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 183 | end 184 | end 185 | end 186 | 187 | context 'related record is nil' do 188 | let(:related_record) { nil } 189 | it 'does not raise any errors' do 190 | expect { subject }.not_to raise_error 191 | end 192 | end 193 | end 194 | 195 | context 'unauthorized for show? on source record' do 196 | before { disallow_action(source_record, 'show?') } 197 | 198 | context 'related record is present' do 199 | let(:related_record) { Comment.new } 200 | 201 | context 'authorized for show on related record' do 202 | before { allow_action(related_record, 'show?') } 203 | it 'raises a NotAuthorizedError' do 204 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 205 | end 206 | end 207 | 208 | context 'unauthorized for show on related record' do 209 | before { disallow_action(related_record, 'show?') } 210 | it 'raises a NotAuthorizedError' do 211 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 212 | end 213 | end 214 | end 215 | 216 | context 'related record is nil' do 217 | let(:related_record) { nil } 218 | it 'raises a NotAuthorizedError' do 219 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 220 | end 221 | end 222 | end 223 | end 224 | 225 | describe '#show_related_resources' do 226 | let(:related_record) { Comment.new } 227 | 228 | subject(:method_call) do 229 | authorizer.show_related_resources(source_record: source_record, 230 | related_record_class: related_record) 231 | end 232 | 233 | context 'authorized for show? on source record' do 234 | before { allow_action(source_record, 'show?') } 235 | 236 | context 'authorized for index? on related record' do 237 | before { allow_action(related_record, 'index?') } 238 | it 'does not raise any errors' do 239 | expect { subject }.not_to raise_error 240 | end 241 | end 242 | 243 | context 'unauthorized for index? on related record' do 244 | before { disallow_action(related_record, 'index?') } 245 | it 'raises a NotAuthorizedError' do 246 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 247 | end 248 | end 249 | end 250 | 251 | context 'unauthorized for show? on record' do 252 | before { disallow_action(source_record, 'show?') } 253 | 254 | context 'authorized for index? on related record' do 255 | before { allow_action(related_record, 'index?') } 256 | it 'raises a NotAuthorizedError' do 257 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 258 | end 259 | end 260 | 261 | context 'unauthorized for index? on related record' do 262 | before { disallow_action(related_record, 'index?') } 263 | it 'raises a NotAuthorizedError' do 264 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 265 | end 266 | end 267 | end 268 | end 269 | 270 | describe '#replace_fields' do 271 | describe 'with "relation_type: :to_one"' do 272 | let(:related_record) { User.new } 273 | let(:related_records_with_context) do 274 | [{ 275 | relation_name: :author, 276 | relation_type: :to_one, 277 | records: related_record 278 | }] 279 | end 280 | 281 | subject(:method_call) do 282 | authorizer.replace_fields( 283 | source_record: source_record, 284 | related_records_with_context: related_records_with_context 285 | ) 286 | end 287 | 288 | context 'authorized for replace_? and authorized for update? on source record' do 289 | before { stub_policy_actions(source_record, replace_author?: true, update?: true) } 290 | it 'does not raise any errors' do 291 | expect { subject }.not_to raise_error 292 | end 293 | end 294 | 295 | context 'unauthorized for replace_? and authorized for update? on source record' do 296 | before { stub_policy_actions(source_record, replace_author?: false, update?: true) } 297 | it 'raises a NotAuthorizedError' do 298 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 299 | end 300 | end 301 | 302 | context 'authorized for replace_? and unauthorized for update? on source record' do 303 | before { stub_policy_actions(source_record, replace_author?: true, update?: false) } 304 | it 'raises a NotAuthorizedError' do 305 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 306 | end 307 | end 308 | 309 | context 'unauthorized for replace_? and unauthorized for update? on source record' do 310 | before { stub_policy_actions(source_record, replace_author?: false, update?: false) } 311 | it 'raises a NotAuthorizedError' do 312 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 313 | end 314 | end 315 | 316 | context 'where replace_? is undefined' do 317 | context 'authorized for update? on source record' do 318 | before { stub_policy_actions(source_record, update?: true) } 319 | include_examples :update_singular_fallback, :related_record 320 | end 321 | 322 | context 'unauthorized for update? on source record' do 323 | before { stub_policy_actions(source_record, update?: false) } 324 | it 'raises a NotAuthorizedError' do 325 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 326 | end 327 | end 328 | end 329 | end 330 | 331 | describe 'with "relation_type: :to_one" and records is nil' do 332 | let(:related_records_with_context) do 333 | [{ 334 | relation_name: :author, 335 | relation_type: :to_one, 336 | records: nil 337 | }] 338 | end 339 | 340 | subject(:method_call) do 341 | authorizer.replace_fields( 342 | source_record: source_record, 343 | related_records_with_context: related_records_with_context 344 | ) 345 | end 346 | 347 | context 'authorized for remove_? and authorized for update? on source record' do 348 | before { stub_policy_actions(source_record, remove_author?: true, update?: true) } 349 | it 'does not raise any errors' do 350 | expect { subject }.not_to raise_error 351 | end 352 | end 353 | 354 | context 'unauthorized for remove_? and authorized for update? on source record' do 355 | before { stub_policy_actions(source_record, remove_author?: false, update?: true) } 356 | it 'raises a NotAuthorizedError' do 357 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 358 | end 359 | end 360 | 361 | context 'authorized for remove_? and unauthorized for update? on source record' do 362 | before { stub_policy_actions(source_record, remove_author?: true, update?: false) } 363 | it 'raises a NotAuthorizedError' do 364 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 365 | end 366 | end 367 | 368 | context 'unauthorized for remove_? and unauthorized for update? on source record' do 369 | before { stub_policy_actions(source_record, remove_author?: false, update?: false) } 370 | it 'raises a NotAuthorizedError' do 371 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 372 | end 373 | end 374 | 375 | context 'where remove_? is undefined' do 376 | context 'authorized for update? on source record' do 377 | before { stub_policy_actions(source_record, update?: true) } 378 | it 'does not raise any errors' do 379 | expect { subject }.not_to raise_error 380 | end 381 | end 382 | 383 | context 'unauthorized for update? on source record' do 384 | before { stub_policy_actions(source_record, update?: false) } 385 | it 'raises a NotAuthorizedError' do 386 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 387 | end 388 | end 389 | end 390 | end 391 | 392 | describe 'with "relation_type: :to_many"' do 393 | let(:related_records) { Array.new(3) { Comment.new } } 394 | let(:related_records_with_context) do 395 | [{ 396 | relation_name: :comments, 397 | relation_type: :to_many, 398 | records: related_records 399 | }] 400 | end 401 | 402 | subject(:method_call) do 403 | authorizer.replace_fields( 404 | source_record: source_record, 405 | related_records_with_context: related_records_with_context 406 | ) 407 | end 408 | 409 | context 'authorized for update? on source record and related records is empty' do 410 | before { allow_action(source_record, 'update?') } 411 | let(:related_records) { [] } 412 | it 'does not raise any errors' do 413 | expect { subject }.not_to raise_error 414 | end 415 | end 416 | 417 | context 'unauthorized for update? on source record and related records is empty' do 418 | before { disallow_action(source_record, 'update?') } 419 | let(:related_records) { [] } 420 | it 'raises a NotAuthorizedError' do 421 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 422 | end 423 | end 424 | 425 | context 'authorized for replace_? and authorized for update? on source record' do 426 | before { stub_policy_actions(source_record, replace_comments?: true, update?: true) } 427 | it 'does not raise any errors' do 428 | expect { subject }.not_to raise_error 429 | end 430 | end 431 | 432 | context 'unauthorized for replace_? and authorized for update? on source record' do 433 | before { stub_policy_actions(source_record, replace_comments?: false, update?: true) } 434 | it 'raises a NotAuthorizedError' do 435 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 436 | end 437 | end 438 | 439 | context 'authorized for replace_? and unauthorized for update? on source record' do 440 | before { stub_policy_actions(source_record, replace_comments?: true, update?: false) } 441 | it 'raises a NotAuthorizedError' do 442 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 443 | end 444 | end 445 | 446 | context 'unauthorized for replace_? and unauthorized for update? on source record' do 447 | before { stub_policy_actions(source_record, replace_comments?: false, update?: false) } 448 | it 'raises a NotAuthorizedError' do 449 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 450 | end 451 | end 452 | 453 | context 'where replace_? is undefined' do 454 | context 'authorized for update? on source record' do 455 | before { stub_policy_actions(source_record, update?: true) } 456 | include_examples :update_multiple_fallback, :related_records 457 | end 458 | 459 | context 'unauthorized for update? on source record' do 460 | before { stub_policy_actions(source_record, update?: false) } 461 | it 'raises a NotAuthorizedError' do 462 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 463 | end 464 | end 465 | end 466 | end 467 | end 468 | 469 | describe '#create_resource' do 470 | describe 'with "relation_type: :to_one"' do 471 | let(:related_record) { User.new } 472 | let(:related_records_with_context) do 473 | [{ 474 | relation_name: :author, 475 | relation_type: :to_one, 476 | records: related_record 477 | }] 478 | end 479 | let(:source_class) { source_record.class } 480 | subject(:method_call) do 481 | authorizer.create_resource( 482 | source_class: source_class, 483 | related_records_with_context: related_records_with_context 484 | ) 485 | end 486 | 487 | context 'authorized for create? and authorized for create_with_? on source class' do 488 | before { stub_policy_actions(source_class, create_with_author?: true, create?: true) } 489 | it 'does not raise any errors' do 490 | expect { subject }.not_to raise_error 491 | end 492 | end 493 | 494 | context 'authorized for create? and unauthorized for create_with_? on source class' do 495 | before { stub_policy_actions(source_class, create_with_author?: false, create?: true) } 496 | it 'raises a NotAuthorizedError' do 497 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 498 | end 499 | end 500 | 501 | context 'unauthorized for create? and authorized for create_with_? on source class' do 502 | before { stub_policy_actions(source_class, create_with_author?: true, create?: false) } 503 | it 'raises a NotAuthorizedError' do 504 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 505 | end 506 | end 507 | 508 | context 'unauthorized for create? and unauthorized for create_with_? on source class' do 509 | before { stub_policy_actions(source_class, create_with_author?: false, create?: false) } 510 | it 'raises a NotAuthorizedError' do 511 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 512 | end 513 | end 514 | 515 | context 'where create_with_? is undefined' do 516 | context 'authorized for create? on source class' do 517 | before { stub_policy_actions(source_class, create?: true) } 518 | include_examples :update_singular_fallback, :related_record 519 | end 520 | 521 | context 'unauthorized for create? on source class' do 522 | before { stub_policy_actions(source_class, create?: false) } 523 | it 'raises a NotAuthorizedError' do 524 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 525 | end 526 | end 527 | end 528 | end 529 | 530 | describe 'with "relation_type: :to_many"' do 531 | let(:related_records) { Array.new(3) { Comment.new } } 532 | let(:related_records_with_context) do 533 | [{ 534 | relation_name: :comments, 535 | relation_type: :to_many, 536 | records: related_records 537 | }] 538 | end 539 | let(:source_class) { source_record.class } 540 | subject(:method_call) do 541 | authorizer.create_resource( 542 | source_class: source_class, 543 | related_records_with_context: related_records_with_context 544 | ) 545 | end 546 | 547 | context 'authorized for create? on source class and related records is empty' do 548 | before { stub_policy_actions(source_class, create?: true) } 549 | let(:related_records) { [] } 550 | it 'does not raise any errors' do 551 | expect { subject }.not_to raise_error 552 | end 553 | end 554 | 555 | context 'authorized for create? and authorized for create_with_? on source class' do 556 | before { stub_policy_actions(source_class, create_with_comments?: true, create?: true) } 557 | it 'does not raise any errors' do 558 | expect { subject }.not_to raise_error 559 | end 560 | end 561 | 562 | context 'authorized for create? and unauthorized for create_with_? on source class' do 563 | let(:related_records) { [Comment.new(id: 1), Comment.new(id: 2)] } 564 | before { stub_policy_actions(source_class, create_with_comments?: false, create?: true) } 565 | it 'raises a NotAuthorizedError' do 566 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 567 | end 568 | end 569 | 570 | context 'unauthorized for create? on source class and related records is empty' do 571 | let(:related_records) { [] } 572 | before { stub_policy_actions(source_class, create?: false) } 573 | it 'raises a NotAuthorizedError' do 574 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 575 | end 576 | end 577 | 578 | context 'unauthorized for create? and authorized for create_with_? on source class' do 579 | before { stub_policy_actions(source_class, create_with_comments?: true, create?: false) } 580 | it 'raises a NotAuthorizedError' do 581 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 582 | end 583 | end 584 | 585 | context 'unauthorized for create? and unauthorized for create_with_? on source class' do 586 | let(:related_records) { [Comment.new(id: 1), Comment.new(id: 2)] } 587 | before { stub_policy_actions(source_class, create_with_comments?: false, create?: false) } 588 | it 'raises a NotAuthorizedError' do 589 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 590 | end 591 | end 592 | 593 | context 'where create_with_? is undefined' do 594 | context 'authorized for create? on source class' do 595 | before { stub_policy_actions(source_class, create?: true) } 596 | include_examples :update_multiple_fallback, :related_records 597 | end 598 | 599 | context 'unauthorized for create? on source class' do 600 | before { stub_policy_actions(source_class, create?: false) } 601 | it 'raises a NotAuthorizedError' do 602 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 603 | end 604 | end 605 | end 606 | end 607 | end 608 | 609 | describe '#remove_resource' do 610 | subject(:method_call) do 611 | authorizer.remove_resource(source_record: source_record) 612 | end 613 | 614 | context 'authorized for destroy? on record' do 615 | before { allow_action(source_record, 'destroy?') } 616 | it 'does not raise any errors' do 617 | expect { subject }.not_to raise_error 618 | end 619 | end 620 | 621 | context 'unauthorized for destroy? on record' do 622 | before { disallow_action(source_record, 'destroy?') } 623 | it 'raises a NotAuthorizedError' do 624 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 625 | end 626 | end 627 | end 628 | 629 | describe '#replace_to_one_relationship' do 630 | let(:related_record) { User.new } 631 | subject(:method_call) do 632 | authorizer.replace_to_one_relationship( 633 | source_record: source_record, 634 | new_related_record: related_record, 635 | relationship_type: :author 636 | ) 637 | end 638 | 639 | context 'authorized for replace_? and update? on record' do 640 | before { stub_policy_actions(source_record, replace_author?: true, update?: true) } 641 | it 'does not raise any errors' do 642 | expect { subject }.not_to raise_error 643 | end 644 | end 645 | 646 | context 'unauthorized for replace_? and authorized for update? on record' do 647 | before { stub_policy_actions(source_record, replace_author?: false, update?: true) } 648 | it 'raises a NotAuthorizedError' do 649 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 650 | end 651 | end 652 | 653 | context 'authorized for replace_? and unauthorized for update? on record' do 654 | before { stub_policy_actions(source_record, replace_author?: true, update?: false) } 655 | it 'does not raise any errors' do 656 | expect { subject }.not_to raise_error 657 | end 658 | end 659 | 660 | context 'unauthorized for replace_? and update? on record' do 661 | before { stub_policy_actions(source_record, replace_author?: false, update?: false) } 662 | it 'raises a NotAuthorizedError' do 663 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 664 | end 665 | end 666 | 667 | context 'where replace_? is undefined' do 668 | context 'authorized for update? on source record' do 669 | before { stub_policy_actions(source_record, update?: true) } 670 | include_examples :update_singular_fallback, :related_record 671 | end 672 | 673 | context 'unauthorized for update? on source record' do 674 | before { stub_policy_actions(source_record, update?: false) } 675 | it 'raises a NotAuthorizedError' do 676 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 677 | end 678 | end 679 | end 680 | end 681 | 682 | describe '#create_to_many_relationship' do 683 | let(:related_records) { Array.new(3) { Comment.new } } 684 | subject(:method_call) do 685 | authorizer.create_to_many_relationship( 686 | source_record: source_record, 687 | new_related_records: related_records, 688 | relationship_type: :comments 689 | ) 690 | end 691 | 692 | context 'authorized for add_to_? and update? on record' do 693 | before { stub_policy_actions(source_record, add_to_comments?: true, update?: true) } 694 | it 'does not raise any errors' do 695 | expect { subject }.not_to raise_error 696 | end 697 | end 698 | 699 | context 'unauthorized for add_to_? and authorized for update? on record' do 700 | before { stub_policy_actions(source_record, add_to_comments?: false, update?: true) } 701 | it 'raises a NotAuthorizedError' do 702 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 703 | end 704 | end 705 | 706 | context 'authorized for add_to_? and unauthorized for update? on record' do 707 | before { stub_policy_actions(source_record, add_to_comments?: true, update?: false) } 708 | it 'does not raise any errors' do 709 | expect { subject }.not_to raise_error 710 | end 711 | end 712 | 713 | context 'unauthorized for add_to_? and update? on record' do 714 | before { stub_policy_actions(source_record, add_to_comments?: false, update?: false) } 715 | it 'raises a NotAuthorizedError' do 716 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 717 | end 718 | end 719 | 720 | context 'where add_to_? not defined' do 721 | context 'authorized for update? on record' do 722 | before { stub_policy_actions(source_record, update?: true) } 723 | include_examples :update_multiple_fallback, :related_records 724 | end 725 | 726 | context 'unauthorized for update? on record' do 727 | before { stub_policy_actions(source_record, update?: false) } 728 | it 'raises a NotAuthorizedError' do 729 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 730 | end 731 | end 732 | end 733 | end 734 | 735 | describe '#replace_to_many_relationship' do 736 | let(:article) { articles(:article_with_comments) } 737 | let(:new_comments) { Array.new(3) { Comment.new } } 738 | subject(:method_call) do 739 | authorizer.replace_to_many_relationship( 740 | source_record: article, 741 | new_related_records: new_comments, 742 | relationship_type: :comments 743 | ) 744 | end 745 | 746 | context 'authorized for replace_? and update? on record' do 747 | before { stub_policy_actions(article, replace_comments?: true, update?: true) } 748 | it 'does not raise any errors' do 749 | expect { subject }.not_to raise_error 750 | end 751 | end 752 | 753 | context 'unauthorized for replace_? and authorized for update? on record' do 754 | before { stub_policy_actions(article, replace_comments?: false, update?: true) } 755 | it 'raises a NotAuthorizedError' do 756 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 757 | end 758 | end 759 | 760 | context 'authorized for replace_? and unauthorized for update? on record' do 761 | before { stub_policy_actions(article, replace_comments?: true, update?: false) } 762 | it 'does not raise any errors' do 763 | expect { subject }.not_to raise_error 764 | end 765 | end 766 | 767 | context 'unauthorized for replace_? and update? on record' do 768 | before { stub_policy_actions(article, replace_comments?: false, update?: false) } 769 | it 'raises a NotAuthorizedError' do 770 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 771 | end 772 | end 773 | 774 | context 'where replace_? not defined' do 775 | context 'authorized for update? on record' do 776 | before { stub_policy_actions(article, update?: true) } 777 | include_examples :update_multiple_fallback, :new_comments 778 | end 779 | 780 | context 'unauthorized for update? on record' do 781 | before { stub_policy_actions(article, update?: false) } 782 | it 'raises a NotAuthorizedError' do 783 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 784 | end 785 | end 786 | end 787 | end 788 | 789 | describe '#remove_to_many_relationship' do 790 | let(:article) { articles(:article_with_comments) } 791 | let(:comments_to_remove) { article.comments.limit(2) } 792 | subject(:method_call) do 793 | authorizer.remove_to_many_relationship( 794 | source_record: article, 795 | related_records: comments_to_remove, 796 | relationship_type: :comments 797 | ) 798 | end 799 | 800 | context 'authorized for remove_from_? and article? on article' do 801 | before { stub_policy_actions(article, remove_from_comments?: true, update?: true) } 802 | 803 | it 'does not raise any errors' do 804 | expect { subject }.not_to raise_error 805 | end 806 | end 807 | 808 | context 'unauthorized for remove_from_? and authorized for update? on article' do 809 | before { stub_policy_actions(article, remove_from_comments?: false, update?: true) } 810 | it 'raises a NotAuthorizedError' do 811 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 812 | end 813 | end 814 | 815 | context 'authorized for remove_from_? and unauthorized for update? on article' do 816 | before { stub_policy_actions(article, remove_from_comments?: true, update?: false) } 817 | it 'does not raise any errors' do 818 | expect { subject }.not_to raise_error 819 | end 820 | end 821 | 822 | context 'unauthorized for remove_from_? and update? on article' do 823 | before { stub_policy_actions(article, remove_from_comments?: false, update?: false) } 824 | it 'raises a NotAuthorizedError' do 825 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 826 | end 827 | end 828 | 829 | context 'where remove_from_? not defined' do 830 | context 'authorized for update? on article' do 831 | before { stub_policy_actions(article, update?: true) } 832 | include_examples :update_multiple_fallback, :comments_to_remove 833 | end 834 | 835 | context 'unauthorized for update? on article' do 836 | before { stub_policy_actions(article, update?: false) } 837 | it 'raises a NotAuthorizedError' do 838 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 839 | end 840 | end 841 | end 842 | end 843 | 844 | describe '#remove_to_one_relationship' do 845 | subject(:method_call) do 846 | authorizer.remove_to_one_relationship( 847 | source_record: source_record, relationship_type: :author 848 | ) 849 | end 850 | 851 | context 'authorized for remove_? and article? on record' do 852 | before { stub_policy_actions(source_record, remove_author?: true, update?: true) } 853 | it 'does not raise any errors' do 854 | expect { subject }.not_to raise_error 855 | end 856 | end 857 | 858 | context 'unauthorized for remove_? and authorized for update? on record' do 859 | before { stub_policy_actions(source_record, remove_author?: false, update?: true) } 860 | it 'raises a NotAuthorizedError' do 861 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 862 | end 863 | end 864 | 865 | context 'authorized for remove_? and unauthorized for update? on record' do 866 | before { stub_policy_actions(source_record, remove_author?: true, update?: false) } 867 | it 'does not raise any errors' do 868 | expect { subject }.not_to raise_error 869 | end 870 | end 871 | 872 | context 'unauthorized for remove_? and update? on record' do 873 | before { stub_policy_actions(source_record, remove_author?: false, update?: false) } 874 | it 'raises a NotAuthorizedError' do 875 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 876 | end 877 | end 878 | 879 | context 'where remove_? not defined' do 880 | context 'authorized for update? on record' do 881 | before { stub_policy_actions(source_record, update?: true) } 882 | it 'does not raise any errors' do 883 | expect { subject }.not_to raise_error 884 | end 885 | end 886 | 887 | context 'unauthorized for update? on record' do 888 | before { stub_policy_actions(source_record, update?: false) } 889 | it 'raises a NotAuthorizedError' do 890 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 891 | end 892 | end 893 | end 894 | end 895 | 896 | describe '#include_has_many_resource' do 897 | let(:record_class) { Article } 898 | let(:source_record) { Comment.new } 899 | subject(:method_call) do 900 | authorizer.include_has_many_resource( 901 | source_record: source_record, record_class: record_class 902 | ) 903 | end 904 | 905 | context 'authorized for index? on record class' do 906 | before { allow_action(record_class, 'index?') } 907 | it 'does not raise any errors' do 908 | expect { subject }.not_to raise_error 909 | end 910 | end 911 | 912 | context 'unauthorized for index? on record class' do 913 | before { disallow_action(record_class, 'index?') } 914 | it 'raises a NotAuthorizedError' do 915 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 916 | end 917 | end 918 | end 919 | 920 | describe '#include_has_one_resource' do 921 | let(:related_record) { Article.new } 922 | let(:source_record) { Comment.new } 923 | subject(:method_call) do 924 | authorizer.include_has_one_resource( 925 | source_record: source_record, 926 | related_record: related_record 927 | ) 928 | end 929 | 930 | context 'authorized for show? on record' do 931 | before { allow_action(related_record, 'show?') } 932 | it 'does not raise any errors' do 933 | expect { subject }.not_to raise_error 934 | end 935 | end 936 | 937 | context 'unauthorized for show? on record' do 938 | before { disallow_action(related_record, 'show?') } 939 | it 'raises a NotAuthorizedError' do 940 | expect { subject }.to raise_error(::Pundit::NotAuthorizedError) 941 | end 942 | end 943 | end 944 | end 945 | -------------------------------------------------------------------------------- /spec/requests/custom_name_relationship_operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'including custom name relationships', type: :request do 6 | include AuthorizationStubs 7 | fixtures :all 8 | 9 | subject { last_response } 10 | let(:json_included) { JSON.parse(last_response.body) } 11 | 12 | let(:comments_policy_scope) { Comment.all } 13 | 14 | before do 15 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return( 16 | comments_policy_scope 17 | ) 18 | allow_any_instance_of(CommentPolicy).to receive(:show?).and_return(true) 19 | allow_any_instance_of(UserPolicy).to receive(:show?).and_return(true) 20 | end 21 | 22 | before do 23 | header 'Content-Type', 'application/vnd.api+json' 24 | end 25 | 26 | describe 'GET /comments/:id/reviewer' do 27 | subject(:last_response) { get("/comments/#{Comment.first.id}/reviewer") } 28 | context "access authorized" do 29 | before do 30 | allow_any_instance_of(CommentPolicy).to receive(:show?).and_return(true) 31 | allow_any_instance_of(UserPolicy).to receive(:show?).and_return(true) 32 | end 33 | it { is_expected.to be_ok } 34 | end 35 | 36 | context "access to reviewer forbidden" do 37 | before do 38 | allow_any_instance_of(CommentPolicy).to receive(:show?).and_return(true) 39 | allow_any_instance_of(UserPolicy).to receive(:show?).and_return(false) 40 | end 41 | it { is_expected.to be_forbidden } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/requests/included_resources_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'including resources alongside normal operations', type: :request do 6 | include AuthorizationStubs 7 | fixtures :all 8 | 9 | subject { last_response } 10 | let(:json_included) { JSON.parse(last_response.body)['included'] } 11 | 12 | let(:comments_policy_scope) { Comment.all } 13 | let(:article_policy_scope) { Article.all } 14 | let(:user_policy_scope) { User.all } 15 | 16 | # Take the stubbed scope and call merge(policy_scope.scope.all) so that the original 17 | # scope's conditions are not lost. Without it, the stub will always return all records 18 | # the user has access to regardless of context. 19 | before do 20 | allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve) do |policy_scope| 21 | article_policy_scope.merge(policy_scope.scope.all) 22 | end 23 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve) do |policy_scope| 24 | comments_policy_scope.merge(policy_scope.scope.all) 25 | end 26 | allow_any_instance_of(UserPolicy::Scope).to receive(:resolve) do |policy_scope| 27 | user_policy_scope.merge(policy_scope.scope.all) 28 | end 29 | end 30 | 31 | before do 32 | header 'Content-Type', 'application/vnd.api+json' 33 | end 34 | 35 | shared_examples_for :include_directive_tests do 36 | describe 'one-level deep has_many relationship' do 37 | let(:include_query) { 'comments' } 38 | 39 | context 'unauthorized for include_has_many_resource for Comment' do 40 | before do 41 | disallow_operation( 42 | 'include_has_many_resource', 43 | source_record: an_instance_of(Article), 44 | record_class: Comment, 45 | authorizer: chained_authorizer 46 | ) 47 | end 48 | 49 | it { is_expected.to be_forbidden } 50 | end 51 | 52 | context 'authorized for include_has_many_resource for Comment' do 53 | before do 54 | allow_operation( 55 | 'include_has_many_resource', 56 | source_record: an_instance_of(Article), 57 | record_class: Comment, 58 | authorizer: chained_authorizer 59 | ) 60 | end 61 | 62 | it { is_expected.to be_successful } 63 | 64 | it 'includes only comments allowed by policy scope and associated with the article' do 65 | expect(json_included.length).to eq(article.comments.count) 66 | expect( 67 | json_included.map { |included| included["id"].to_i } 68 | ).to match_array(article.comments.map(&:id)) 69 | end 70 | end 71 | end 72 | 73 | describe 'one-level deep has_one relationship' do 74 | let(:include_query) { 'author' } 75 | 76 | context 'unauthorized for include_has_one_resource for article.author' do 77 | before do 78 | disallow_operation( 79 | 'include_has_one_resource', 80 | source_record: an_instance_of(Article), 81 | related_record: an_instance_of(User), 82 | authorizer: chained_authorizer 83 | ) 84 | end 85 | 86 | it { is_expected.to be_forbidden } 87 | end 88 | 89 | context 'authorized for include_has_one_resource for article.author' do 90 | before do 91 | allow_operation( 92 | 'include_has_one_resource', 93 | source_record: an_instance_of(Article), 94 | related_record: an_instance_of(User), 95 | authorizer: chained_authorizer 96 | ) 97 | end 98 | 99 | it { is_expected.to be_successful } 100 | 101 | it 'includes the associated author resource' do 102 | json_users = json_included.select { |i| i['type'] == 'users' } 103 | expect(json_users).to include(a_hash_including('id' => article.author.id.to_s)) 104 | end 105 | end 106 | end 107 | 108 | describe 'multiple one-level deep relationships' do 109 | let(:include_query) { 'author,comments' } 110 | 111 | context 'unauthorized for include_has_one_resource for article.author' do 112 | before do 113 | disallow_operation( 114 | 'include_has_one_resource', 115 | source_record: an_instance_of(Article), 116 | related_record: an_instance_of(User), 117 | authorizer: chained_authorizer 118 | ) 119 | end 120 | 121 | it { is_expected.to be_forbidden } 122 | end 123 | 124 | context 'unauthorized for include_has_many_resource for Comment' do 125 | before do 126 | allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) 127 | disallow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Comment, authorizer: chained_authorizer) 128 | end 129 | 130 | it { is_expected.to be_forbidden } 131 | end 132 | 133 | context 'authorized for both operations' do 134 | before do 135 | allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) 136 | allow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Comment, authorizer: chained_authorizer) 137 | end 138 | 139 | it { is_expected.to be_successful } 140 | 141 | it 'includes only comments allowed by policy scope and associated with the article' do 142 | json_comments = json_included.select { |item| item['type'] == 'comments' } 143 | expect(json_comments.length).to eq(article.comments.count) 144 | expect( 145 | json_comments.map { |i| i['id'] } 146 | ).to match_array(article.comments.pluck(:id).map(&:to_s)) 147 | end 148 | 149 | it 'includes the associated author resource' do 150 | json_users = json_included.select { |item| item['type'] == 'users' } 151 | expect(json_users).to include(a_hash_including('id' => article.author.id.to_s)) 152 | end 153 | end 154 | end 155 | 156 | describe 'a deep relationship' do 157 | let(:include_query) { 'author.comments' } 158 | 159 | context 'unauthorized for first relationship' do 160 | before do 161 | disallow_operation( 162 | 'include_has_one_resource', 163 | source_record: an_instance_of(Article), 164 | related_record: an_instance_of(User), 165 | authorizer: chained_authorizer 166 | ) 167 | end 168 | 169 | it { is_expected.to be_forbidden } 170 | end 171 | 172 | context 'authorized for first relationship' do 173 | before { allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) } 174 | 175 | context 'unauthorized for second relationship' do 176 | before { disallow_operation('include_has_many_resource', source_record: an_instance_of(User), record_class: Comment, authorizer: chained_authorizer) } 177 | 178 | it { is_expected.to be_forbidden } 179 | end 180 | 181 | context 'authorized for second relationship' do 182 | before { allow_operation('include_has_many_resource', source_record: an_instance_of(User), record_class: Comment, authorizer: chained_authorizer) } 183 | 184 | it { is_expected.to be_successful } 185 | 186 | it 'includes the first level resource' do 187 | json_users = json_included.select { |item| item['type'] == 'users' } 188 | expect(json_users).to include(a_hash_including('id' => article.author.id.to_s)) 189 | end 190 | 191 | describe 'second level resources' do 192 | it 'includes only resources allowed by policy scope' do 193 | second_level_items = json_included.select { |item| item['type'] == 'comments' } 194 | expect(second_level_items.length).to eq(article.author.comments.count) 195 | expect( 196 | second_level_items.map { |i| i['id'] } 197 | ).to match_array(article.author.comments.pluck(:id).map(&:to_s)) 198 | end 199 | end 200 | end 201 | end 202 | end 203 | 204 | describe 'a deep relationship with empty relations' do 205 | context 'first level has_one is nil' do 206 | let(:include_query) { 'non-existing-article.comments' } 207 | 208 | it { is_expected.to be_successful } 209 | end 210 | 211 | context 'first level has_many is empty' do 212 | let(:include_query) { 'empty-articles.comments' } 213 | 214 | context 'unauthorized for first relationship' do 215 | before { disallow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Article, authorizer: chained_authorizer) } 216 | 217 | it { is_expected.to be_forbidden } 218 | end 219 | 220 | context 'authorized for first relationship' do 221 | before { allow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Article, authorizer: chained_authorizer) } 222 | 223 | it { is_expected.to be_successful } 224 | end 225 | end 226 | end 227 | end 228 | 229 | shared_examples_for :scope_limited_directive_tests do 230 | describe 'one-level deep has_many relationship' do 231 | let(:comments_policy_scope) { Comment.where(id: article.comments.first.id) } 232 | let(:include_query) { 'comments' } 233 | 234 | context 'authorized for include_has_many_resource for Comment' do 235 | before do 236 | allow_operation( 237 | 'include_has_many_resource', 238 | source_record: an_instance_of(Article), 239 | record_class: Comment, 240 | authorizer: chained_authorizer 241 | ) 242 | end 243 | 244 | it { is_expected.to be_successful } 245 | 246 | it 'includes only comments allowed by policy scope' do 247 | expect(json_included.length).to eq(comments_policy_scope.length) 248 | expect(json_included.first["id"]).to eq(comments_policy_scope.first.id.to_s) 249 | end 250 | end 251 | end 252 | 253 | describe 'multiple one-level deep relationships' do 254 | let(:include_query) { 'author,comments' } 255 | let(:comments_policy_scope) { Comment.where(id: article.comments.first.id) } 256 | 257 | context 'authorized for both operations' do 258 | before do 259 | allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) 260 | allow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Comment, authorizer: chained_authorizer) 261 | end 262 | 263 | it { is_expected.to be_successful } 264 | 265 | it 'includes only comments allowed by policy scope and associated with the article' do 266 | json_comments = json_included.select { |item| item['type'] == 'comments' } 267 | expect(json_comments.length).to eq(comments_policy_scope.length) 268 | expect( 269 | json_comments.map { |i| i['id'] } 270 | ).to match_array(comments_policy_scope.pluck(:id).map(&:to_s)) 271 | end 272 | 273 | it 'includes the associated author resource' do 274 | json_users = json_included.select { |item| item['type'] == 'users' } 275 | expect(json_users).to include(a_hash_including('id' => article.author.id.to_s)) 276 | end 277 | end 278 | end 279 | 280 | describe 'a deep relationship' do 281 | let(:include_query) { 'author.comments' } 282 | let(:comments_policy_scope) { Comment.where(id: article.author.comments.first.id) } 283 | 284 | context 'authorized for first relationship' do 285 | before { allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) } 286 | 287 | context 'authorized for second relationship' do 288 | before { allow_operation('include_has_many_resource', source_record: an_instance_of(User), record_class: Comment, authorizer: chained_authorizer) } 289 | 290 | it { is_expected.to be_successful } 291 | 292 | it 'includes the first level resource' do 293 | json_users = json_included.select { |item| item['type'] == 'users' } 294 | expect(json_users).to include(a_hash_including('id' => article.author.id.to_s)) 295 | end 296 | 297 | describe 'second level resources' do 298 | it 'includes only resources allowed by policy scope' do 299 | second_level_items = json_included.select { |item| item['type'] == 'comments' } 300 | expect(second_level_items.length).to eq(comments_policy_scope.length) 301 | expect( 302 | second_level_items.map { |i| i['id'] } 303 | ).to match_array(comments_policy_scope.pluck(:id).map(&:to_s)) 304 | end 305 | end 306 | end 307 | end 308 | end 309 | end 310 | 311 | shared_examples_for :scope_limited_directive_test_modify_relationships do 312 | describe 'one-level deep has_many relationship' do 313 | let(:comments_policy_scope) { Comment.where(id: existing_comments.first.id) } 314 | let(:include_query) { 'comments' } 315 | 316 | context 'authorized for include_has_many_resource for Comment' do 317 | before do 318 | allow_operation( 319 | 'include_has_many_resource', 320 | source_record: an_instance_of(Article), 321 | record_class: Comment, 322 | authorizer: chained_authorizer 323 | ) 324 | end 325 | 326 | it { is_expected.to be_not_found } 327 | end 328 | end 329 | 330 | describe 'multiple one-level deep relationships' do 331 | let(:include_query) { 'author,comments' } 332 | let(:comments_policy_scope) { Comment.where(id: existing_comments.first.id) } 333 | 334 | context 'authorized for both operations' do 335 | before do 336 | allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) 337 | allow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Comment, authorizer: chained_authorizer) 338 | end 339 | 340 | it { is_expected.to be_not_found } 341 | end 342 | end 343 | 344 | describe 'a deep relationship' do 345 | let(:include_query) { 'author.comments' } 346 | let(:comments_policy_scope) { Comment.where(id: existing_author.comments.first.id) } 347 | 348 | context 'authorized for first relationship' do 349 | before { allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) } 350 | 351 | context 'authorized for second relationship' do 352 | before { allow_operation('include_has_many_resource', source_record: an_instance_of(User), record_class: Comment, authorizer: chained_authorizer) } 353 | 354 | it { is_expected.to be_not_found } 355 | end 356 | end 357 | end 358 | end 359 | 360 | describe 'GET /articles' do 361 | subject(:last_response) { get("/articles?include=#{include_query}") } 362 | let!(:chained_authorizer) { allow_operation('find', source_class: Article) } 363 | 364 | let(:article) do 365 | Article.create( 366 | external_id: "indifferent_external_id", 367 | author: User.create( 368 | comments: Array.new(2) { Comment.create } 369 | ), 370 | comments: Array.new(2) { Comment.create } 371 | ) 372 | end 373 | 374 | let(:article_policy_scope) { Article.where(id: article.id) } 375 | 376 | # TODO: Test properly with multiple articles, not just one. 377 | include_examples :include_directive_tests 378 | include_examples :scope_limited_directive_tests 379 | end 380 | 381 | describe 'GET /articles/:id' do 382 | let(:article) do 383 | Article.create( 384 | external_id: "indifferent_external_id", 385 | author: User.create( 386 | comments: Array.new(2) { Comment.create } 387 | ), 388 | comments: Array.new(2) { Comment.create } 389 | ) 390 | end 391 | 392 | subject(:last_response) { get("/articles/#{article.external_id}?include=#{include_query}") } 393 | let!(:chained_authorizer) { allow_operation('show', source_record: article) } 394 | 395 | include_examples :include_directive_tests 396 | include_examples :scope_limited_directive_tests 397 | end 398 | 399 | describe 'PATCH /articles/:id' do 400 | let(:article) do 401 | Article.create( 402 | external_id: "indifferent_external_id", 403 | author: User.create( 404 | comments: Array.new(2) { Comment.create } 405 | ), 406 | comments: Array.new(2) { Comment.create } 407 | ) 408 | end 409 | 410 | let(:attributes_json) { '{}' } 411 | let(:json) do 412 | <<-JSON.strip_heredoc 413 | { 414 | "data": { 415 | "type": "articles", 416 | "id": "#{article.external_id}", 417 | "attributes": #{attributes_json} 418 | } 419 | } 420 | JSON 421 | end 422 | subject(:last_response) { patch("/articles/#{article.external_id}?include=#{include_query}", json) } 423 | let!(:chained_authorizer) { allow_operation('replace_fields', source_record: article, related_records_with_context: []) } 424 | 425 | include_examples :include_directive_tests 426 | include_examples :scope_limited_directive_tests 427 | 428 | context 'the request has already failed validations' do 429 | let(:include_query) { 'author.comments' } 430 | let(:attributes_json) { '{ "blank-value": "indifferent" }' } 431 | 432 | it 'does not run include authorizations and fails with validation error' do 433 | expect(last_response).to be_unprocessable 434 | end 435 | end 436 | end 437 | 438 | describe 'POST /articles/:id' do 439 | let(:existing_author) do 440 | User.create( 441 | comments: Array.new(2) { Comment.create } 442 | ) 443 | end 444 | let(:existing_comments) do 445 | Array.new(2) { Comment.create } 446 | end 447 | let(:related_records_with_context) do 448 | [ 449 | { 450 | relation_type: :to_one, 451 | relation_name: :author, 452 | records: existing_author 453 | }, 454 | { 455 | relation_type: :to_many, 456 | relation_name: :comments, 457 | # Relax the constraints of expected records here. Lower level tests modify the 458 | # available policy scope for comments, so we will get a different amount of records deep 459 | # down in the other specs. 460 | # 461 | # This is fine, because we test resource create relationships with specific matcher 462 | records: kind_of(Enumerable) 463 | } 464 | ] 465 | end 466 | 467 | let(:attributes_json) { '{}' } 468 | let(:json) do 469 | <<-JSON.strip_heredoc 470 | { 471 | "data": { 472 | "type": "articles", 473 | "id": "indifferent_external_id", 474 | "attributes": #{attributes_json}, 475 | "relationships": { 476 | "comments": { 477 | "data": [ 478 | { "type": "comments", "id": "#{existing_comments.first.id}" }, 479 | { "type": "comments", "id": "#{existing_comments.second.id}" } 480 | ] 481 | }, 482 | "author": { 483 | "data": { 484 | "type": "users", "id": "#{existing_author.id}" 485 | } 486 | } 487 | } 488 | } 489 | } 490 | JSON 491 | end 492 | let(:article) { existing_author.articles.first } 493 | 494 | subject(:last_response) { post("/articles?include=#{include_query}", json) } 495 | let!(:chained_authorizer) do 496 | allow_operation( 497 | 'create_resource', 498 | source_class: Article, 499 | related_records_with_context: related_records_with_context 500 | ) 501 | end 502 | 503 | include_examples :include_directive_tests 504 | include_examples :scope_limited_directive_test_modify_relationships 505 | 506 | context 'the request has already failed validations' do 507 | let(:include_query) { 'author.comments' } 508 | let(:attributes_json) { '{ "blank-value": "indifferent" }' } 509 | 510 | it 'does not run include authorizations and fails with validation error' do 511 | expect(last_response).to be_unprocessable 512 | end 513 | end 514 | end 515 | 516 | describe 'GET /articles/:id/articles' do 517 | let(:article) do 518 | Article.create( 519 | external_id: "indifferent_external_id", 520 | author: User.create( 521 | comments: Array.new(2) { Comment.create } 522 | ), 523 | comments: Array.new(2) { Comment.create } 524 | ) 525 | end 526 | 527 | let(:article_policy_scope) { Article.where(id: article.id) } 528 | 529 | subject(:last_response) { get("/articles/#{article.external_id}/articles?include=#{include_query}") } 530 | let!(:chained_authorizer) { allow_operation('show_related_resources', source_record: article, related_record_class: article.class) } 531 | 532 | include_examples :include_directive_tests 533 | include_examples :scope_limited_directive_tests 534 | end 535 | 536 | describe 'GET /articles/:id/article' do 537 | let(:article) do 538 | Article.create( 539 | external_id: "indifferent_external_id", 540 | author: User.create( 541 | comments: Array.new(2) { Comment.create } 542 | ), 543 | comments: Array.new(2) { Comment.create } 544 | ) 545 | end 546 | 547 | subject(:last_response) { get("/articles/#{article.external_id}/article?include=#{include_query}") } 548 | let!(:chained_authorizer) { allow_operation('show_related_resource', source_record: article, related_record: article) } 549 | 550 | include_examples :include_directive_tests 551 | include_examples :scope_limited_directive_tests 552 | end 553 | end 554 | -------------------------------------------------------------------------------- /spec/requests/related_resources_operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Related resources operations', type: :request do 6 | include AuthorizationStubs 7 | fixtures :all 8 | 9 | let(:article) { Article.all.sample } 10 | let(:authorizations) { {} } 11 | let(:policy_scope) { Article.none } 12 | let(:user_policy_scope) { User.all } 13 | 14 | before do 15 | allow_any_instance_of(UserPolicy::Scope).to receive(:resolve).and_return(user_policy_scope) 16 | end 17 | 18 | let(:json_data) { JSON.parse(last_response.body)["data"] } 19 | 20 | before do 21 | allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve).and_return(policy_scope) 22 | end 23 | 24 | before do 25 | header 'Content-Type', 'application/vnd.api+json' 26 | end 27 | 28 | describe 'GET /articles/:id/comments' do 29 | subject(:last_response) { get("/articles/#{article.external_id}/comments") } 30 | let(:article) { articles(:article_with_comments) } 31 | 32 | let(:policy_scope) { Article.all } 33 | let(:comments_on_article) { article.comments } 34 | let(:comments_class) { comments_on_article.first.class } 35 | let(:comments_policy_scope) { comments_on_article.limit(1) } 36 | 37 | before do 38 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_policy_scope) 39 | end 40 | 41 | context 'unauthorized for show_related_resources' do 42 | before { disallow_operation('show_related_resources', source_record: article, related_record_class: comments_class) } 43 | it { is_expected.to be_forbidden } 44 | end 45 | 46 | context 'authorized for show_related_resources' do 47 | before { allow_operation('show_related_resources', source_record: article, related_record_class: comments_class) } 48 | it { is_expected.to be_ok } 49 | 50 | # If this happens in real life, it's mostly a bug. We want to document the 51 | # behaviour in that case anyway, as it might be surprising. 52 | context 'limited by policy scope' do 53 | let(:policy_scope) { Article.where.not(id: article.id) } 54 | it { is_expected.to be_not_found } 55 | end 56 | 57 | it 'displays only comments allowed by CommentPolicy::Scope' do 58 | expect(json_data.length).to eq(1) 59 | expect(json_data.first["id"]).to eq(comments_policy_scope.first.id.to_s) 60 | end 61 | end 62 | end 63 | 64 | describe 'GET /articles/:id/author' do 65 | subject(:last_response) { get("/articles/#{article.external_id}/author") } 66 | let(:article) { articles(:article_with_author) } 67 | let(:policy_scope) { Article.all } 68 | 69 | context 'unauthorized for show_related_resource' do 70 | before { disallow_operation('show_related_resource', source_record: article, related_record: article.author) } 71 | it { is_expected.to be_forbidden } 72 | end 73 | 74 | context 'authorized for show_related_resource' do 75 | before { allow_operation('show_related_resource', source_record: article, related_record: article.author) } 76 | it { is_expected.to be_ok } 77 | 78 | # If this happens in real life, it's mostly a bug. We want to document the 79 | # behaviour in that case anyway, as it might be surprising. 80 | context 'limited by policy scope' do 81 | let(:policy_scope) { Article.where.not(id: article.id) } 82 | it { is_expected.to be_not_found } 83 | end 84 | end 85 | 86 | context 'authorized for show_related_resource while related resource is limited by policy scope' do 87 | # It might be surprising that with jsonapi-authorization that supports JR 0.9, the `related_record` 88 | # is indeed a real record here and not `nil`. If the policy scope was used, then the `related_record` 89 | # should be `nil` but alas, that is not the case. 90 | before { allow_operation('show_related_resource', source_record: article, related_record: article.author) } 91 | 92 | let(:user_policy_scope) { User.where.not(id: article.author.id) } 93 | 94 | it { is_expected.to be_ok } 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/requests/relationship_operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Relationship operations', type: :request do 6 | include AuthorizationStubs 7 | fixtures :all 8 | 9 | let(:article) { Article.all.sample } 10 | let(:policy_scope) { Article.none } 11 | 12 | let(:json_data) { JSON.parse(last_response.body)["data"] } 13 | 14 | before do 15 | allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve).and_return(policy_scope) 16 | end 17 | 18 | before do 19 | header 'Content-Type', 'application/vnd.api+json' 20 | end 21 | 22 | describe 'GET /articles/:id/relationships/comments' do 23 | let(:article) { articles(:article_with_comments) } 24 | let(:policy_scope) { Article.all } 25 | let(:comments_on_article) { article.comments } 26 | let(:comments_policy_scope) { comments_on_article.limit(1) } 27 | 28 | before do 29 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_policy_scope) 30 | end 31 | subject(:last_response) { get("/articles/#{article.external_id}/relationships/comments") } 32 | 33 | context 'unauthorized for show_relationship' do 34 | before { disallow_operation('show_relationship', source_record: article, related_record: nil) } 35 | it { is_expected.to be_forbidden } 36 | end 37 | 38 | context 'authorized for show_relationship' do 39 | before { allow_operation('show_relationship', source_record: article, related_record: nil) } 40 | it { is_expected.to be_ok } 41 | 42 | # If this happens in real life, it's mostly a bug. We want to document the 43 | # behaviour in that case anyway, as it might be surprising. 44 | context 'limited by ArticlePolicy::Scope' do 45 | let(:policy_scope) { Article.where.not(id: article.id) } 46 | it { is_expected.to be_not_found } 47 | end 48 | 49 | it 'displays only comments allowed by CommentPolicy::Scope' do 50 | expect(json_data.length).to eq(1) 51 | expect(json_data.first["id"]).to eq(comments_policy_scope.first.id.to_s) 52 | end 53 | end 54 | end 55 | 56 | describe 'GET /articles/:id/relationships/author' do 57 | subject(:last_response) { get("/articles/#{article.external_id}/relationships/author") } 58 | 59 | let(:article) { articles(:article_with_author) } 60 | let(:policy_scope) { Article.all } 61 | 62 | context 'unauthorized for show_relationship' do 63 | before { disallow_operation('show_relationship', source_record: article, related_record: article.author) } 64 | it { is_expected.to be_forbidden } 65 | end 66 | 67 | context 'authorized for show_relationship' do 68 | before { allow_operation('show_relationship', source_record: article, related_record: article.author) } 69 | it { is_expected.to be_ok } 70 | 71 | # If this happens in real life, it's mostly a bug. We want to document the 72 | # behaviour in that case anyway, as it might be surprising. 73 | context 'limited by policy scope' do 74 | let(:policy_scope) { Article.where.not(id: article.id) } 75 | it { is_expected.to be_not_found } 76 | end 77 | end 78 | end 79 | 80 | describe 'POST /articles/:id/relationships/comments' do 81 | let(:new_comments) { Array.new(2) { Comment.new }.each(&:save) } 82 | let(:json) do 83 | <<-JSON.strip_heredoc 84 | { 85 | "data": [ 86 | { "type": "comments", "id": "#{new_comments.first.id}" }, 87 | { "type": "comments", "id": "#{new_comments.last.id}" } 88 | ] 89 | } 90 | JSON 91 | end 92 | subject(:last_response) { post("/articles/#{article.external_id}/relationships/comments", json) } 93 | let(:policy_scope) { Article.all } 94 | let(:comments_scope) { Comment.all } 95 | 96 | before do 97 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_scope) 98 | end 99 | 100 | context 'unauthorized for create_to_many_relationship' do 101 | before do 102 | disallow_operation( 103 | 'create_to_many_relationship', 104 | source_record: article, 105 | new_related_records: new_comments, 106 | relationship_type: :comments 107 | ) 108 | end 109 | it { is_expected.to be_forbidden } 110 | end 111 | 112 | context 'authorized for create_to_many_relationship' do 113 | before do 114 | allow_operation( 115 | 'create_to_many_relationship', 116 | source_record: article, 117 | new_related_records: new_comments, 118 | relationship_type: :comments 119 | ) 120 | end 121 | it { is_expected.to be_successful } 122 | 123 | context 'limited by policy scope on comments' do 124 | let(:comments_scope) { Comment.none } 125 | it { is_expected.to be_not_found } 126 | end 127 | 128 | # If this happens in real life, it's mostly a bug. We want to document the 129 | # behaviour in that case anyway, as it might be surprising. 130 | context 'limited by policy scope on articles' do 131 | let(:policy_scope) { Article.where.not(id: article.id) } 132 | it { is_expected.to be_not_found } 133 | end 134 | end 135 | end 136 | 137 | describe 'PATCH /articles/:id/relationships/comments' do 138 | let(:article) { articles(:article_with_comments) } 139 | let(:new_comments) { Array.new(2) { Comment.new }.each(&:save) } 140 | let(:json) do 141 | <<-JSON.strip_heredoc 142 | { 143 | "data": [ 144 | { "type": "comments", "id": "#{new_comments.first.id}" }, 145 | { "type": "comments", "id": "#{new_comments.last.id}" } 146 | ] 147 | } 148 | JSON 149 | end 150 | subject(:last_response) { patch("/articles/#{article.external_id}/relationships/comments", json) } 151 | let(:policy_scope) { Article.all } 152 | let(:comments_scope) { Comment.all } 153 | 154 | before do 155 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_scope) 156 | end 157 | 158 | context 'unauthorized for replace_to_many_relationship' do 159 | before do 160 | disallow_operation('replace_to_many_relationship', source_record: article, new_related_records: new_comments, relationship_type: :comments) 161 | end 162 | 163 | it { is_expected.to be_forbidden } 164 | end 165 | 166 | context 'authorized for replace_to_many_relationship' do 167 | context 'not limited by policy scopes' do 168 | before do 169 | allow_operation('replace_to_many_relationship', source_record: article, new_related_records: new_comments, relationship_type: :comments) 170 | end 171 | 172 | it { is_expected.to be_successful } 173 | end 174 | 175 | context 'limited by policy scope on comments' do 176 | let(:comments_scope) { Comment.none } 177 | before do 178 | allow_operation('replace_to_many_relationship', source_record: article, new_related_records: new_comments, relationship_type: :comments) 179 | end 180 | 181 | it do 182 | pending 'TODO: Maybe this actually should be succesful?' 183 | is_expected.to be_not_found 184 | end 185 | end 186 | 187 | # If this happens in real life, it's mostly a bug. We want to document the 188 | # behaviour in that case anyway, as it might be surprising. 189 | context 'limited by policy scope on articles' do 190 | before do 191 | allow_operation( 192 | 'replace_to_many_relationship', 193 | source_record: article, 194 | new_related_records: new_comments, 195 | relationship_type: :comments 196 | ) 197 | end 198 | let(:policy_scope) { Article.where.not(id: article.id) } 199 | it { is_expected.to be_not_found } 200 | end 201 | end 202 | end 203 | 204 | describe 'PATCH /articles/:id/relationships/author' do 205 | subject(:last_response) { patch("/articles/#{article.external_id}/relationships/author", json) } 206 | 207 | let(:article) { articles(:article_with_author) } 208 | let!(:old_author) { article.author } 209 | let(:policy_scope) { Article.all } 210 | let(:user_policy_scope) { User.all } 211 | 212 | before do 213 | allow_any_instance_of(UserPolicy::Scope).to receive(:resolve).and_return(user_policy_scope) 214 | end 215 | 216 | describe 'when replacing with a new author' do 217 | let(:new_author) { User.create } 218 | let(:json) do 219 | <<-JSON.strip_heredoc 220 | { 221 | "data": { 222 | "type": "users", 223 | "id": "#{new_author.id}" 224 | } 225 | } 226 | JSON 227 | end 228 | 229 | context 'unauthorized for replace_to_one_relationship' do 230 | before { disallow_operation('replace_to_one_relationship', source_record: article, new_related_record: new_author, relationship_type: :author) } 231 | it { is_expected.to be_forbidden } 232 | end 233 | 234 | context 'authorized for replace_to_one_relationship' do 235 | before { allow_operation('replace_to_one_relationship', source_record: article, new_related_record: new_author, relationship_type: :author) } 236 | it { is_expected.to be_successful } 237 | 238 | context 'limited by policy scope on author', skip: 'DISCUSS' do 239 | before do 240 | allow_any_instance_of(UserPolicy::Scope).to receive(:resolve).and_return(user_policy_scope) 241 | end 242 | let(:user_policy_scope) { User.where.not(id: article.author.id) } 243 | it { is_expected.to be_not_found } 244 | end 245 | 246 | # If this happens in real life, it's mostly a bug. We want to document the 247 | # behaviour in that case anyway, as it might be surprising. 248 | context 'limited by policy scope on article' do 249 | let(:policy_scope) { Article.where.not(id: article.id) } 250 | it { is_expected.to be_not_found } 251 | end 252 | end 253 | end 254 | 255 | describe 'when nullifying the author' do 256 | let(:new_author) { nil } 257 | let(:json) { '{ "data": null }' } 258 | 259 | context 'unauthorized for remove_to_one_relationship' do 260 | before { disallow_operation('remove_to_one_relationship', source_record: article, relationship_type: :author) } 261 | it { is_expected.to be_forbidden } 262 | end 263 | 264 | context 'authorized for remove_to_one_relationship' do 265 | before { allow_operation('remove_to_one_relationship', source_record: article, relationship_type: :author) } 266 | it { is_expected.to be_successful } 267 | 268 | context 'limited by policy scope on author', skip: 'DISCUSS' do 269 | let(:user_policy_scope) { User.where.not(id: article.author.id) } 270 | it { is_expected.to be_not_found } 271 | end 272 | 273 | # If this happens in real life, it's mostly a bug. We want to document the 274 | # behaviour in that case anyway, as it might be surprising. 275 | context 'limited by policy scope on article' do 276 | let(:policy_scope) { Article.where.not(id: article.id) } 277 | it { is_expected.to be_not_found } 278 | end 279 | end 280 | end 281 | end 282 | 283 | # Polymorphic has-one relationship replacing 284 | describe 'PATCH /tags/:id/relationships/taggable' do 285 | subject(:last_response) { patch("/tags/#{tag.id}/relationships/taggable", json) } 286 | 287 | let!(:old_taggable) { Comment.create } 288 | let!(:tag) { Tag.create(taggable: old_taggable) } 289 | let(:policy_scope) { Article.all } 290 | let(:comment_policy_scope) { Article.all } 291 | let(:tag_policy_scope) { Tag.all } 292 | 293 | before do 294 | allow_any_instance_of(TagPolicy::Scope).to receive(:resolve).and_return(tag_policy_scope) 295 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comment_policy_scope) 296 | end 297 | 298 | describe 'when replacing with a new taggable' do 299 | let!(:new_taggable) { Article.create(external_id: 'new-article-id') } 300 | let(:json) do 301 | <<-JSON.strip_heredoc 302 | { 303 | "data": { 304 | "type": "articles", 305 | "id": "#{new_taggable.external_id}" 306 | } 307 | } 308 | JSON 309 | end 310 | 311 | context 'unauthorized for replace_to_one_relationship' do 312 | before do 313 | disallow_operation( 314 | 'replace_to_one_relationship', 315 | source_record: tag, 316 | new_related_record: new_taggable, 317 | relationship_type: :taggable 318 | ) 319 | end 320 | it { is_expected.to be_forbidden } 321 | end 322 | 323 | context 'authorized for replace_to_one_relationship' do 324 | before do 325 | allow_operation( 326 | 'replace_to_one_relationship', 327 | source_record: tag, 328 | new_related_record: new_taggable, 329 | relationship_type: :taggable 330 | ) 331 | end 332 | it { is_expected.to be_successful } 333 | 334 | context 'limited by policy scope on taggable', skip: 'DISCUSS' do 335 | let(:policy_scope) { Article.where.not(id: tag.taggable.id) } 336 | it { is_expected.to be_not_found } 337 | end 338 | 339 | # If this happens in real life, it's mostly a bug. We want to document the 340 | # behaviour in that case anyway, as it might be surprising. 341 | context 'limited by policy scope on tag' do 342 | let(:tag_policy_scope) { Tag.where.not(id: tag.id) } 343 | it { is_expected.to be_not_found } 344 | end 345 | end 346 | end 347 | 348 | # https://github.com/cerebris/jsonapi-resources/issues/1081 349 | describe 'when nullifying the taggable', skip: 'Broken upstream' do 350 | let(:new_taggable) { nil } 351 | let(:json) { '{ "data": null }' } 352 | 353 | context 'unauthorized for remove_to_one_relationship' do 354 | before { disallow_operation('remove_to_one_relationship', source_record: tag, relationship_type: :taggable) } 355 | it { is_expected.to be_forbidden } 356 | end 357 | 358 | context 'authorized for remove_to_one_relationship' do 359 | before { allow_operation('remove_to_one_relationship', source_record: tag, relationship_type: :taggable) } 360 | it { is_expected.to be_successful } 361 | 362 | context 'limited by policy scope on taggable', skip: 'DISCUSS' do 363 | let(:policy_scope) { Article.where.not(id: tag.taggable.id) } 364 | it { is_expected.to be_not_found } 365 | end 366 | 367 | # If this happens in real life, it's mostly a bug. We want to document the 368 | # behaviour in that case anyway, as it might be surprising. 369 | context 'limited by policy scope on tag' do 370 | let(:tag_policy_scope) { Tag.where.not(id: tag.id) } 371 | it { is_expected.to be_not_found } 372 | end 373 | end 374 | end 375 | end 376 | 377 | describe 'DELETE /articles/:id/relationships/comments' do 378 | let(:article) { articles(:article_with_comments) } 379 | let(:comments_to_remove) { article.comments.limit(2) } 380 | let(:json) do 381 | <<-JSON.strip_heredoc 382 | { 383 | "data": [ 384 | { "type": "comments", "id": "#{comments_to_remove.first.id}" }, 385 | { "type": "comments", "id": "#{comments_to_remove.last.id}" } 386 | ] 387 | } 388 | JSON 389 | end 390 | subject(:last_response) { delete("/articles/#{article.external_id}/relationships/comments", json) } 391 | let(:policy_scope) { Article.all } 392 | let(:comments_scope) { Comment.all } 393 | 394 | before do 395 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_scope) 396 | end 397 | 398 | context 'unauthorized for remove_to_many_relationship' do 399 | before do 400 | disallow_operation( 401 | 'remove_to_many_relationship', 402 | source_record: article, 403 | related_records: [comments_to_remove.first, comments_to_remove.second], 404 | relationship_type: :comments 405 | ) 406 | end 407 | 408 | it { is_expected.to be_forbidden } 409 | end 410 | 411 | context 'authorized for remove_to_many_relationship' do 412 | context 'not limited by policy scopes' do 413 | before do 414 | allow_operation( 415 | 'remove_to_many_relationship', 416 | source_record: article, 417 | related_records: [comments_to_remove.first, comments_to_remove.second], 418 | relationship_type: :comments 419 | ) 420 | end 421 | 422 | it { is_expected.to be_successful } 423 | end 424 | 425 | context 'limited by policy scope on comments' do 426 | let(:comments_scope) { Comment.none } 427 | before do 428 | disallow_operation('remove_to_many_relationship', source_record: article, related_records: comments_to_remove, relationship_type: :comments) 429 | end 430 | 431 | it { is_expected.to be_not_found } 432 | end 433 | 434 | # If this happens in real life, it's mostly a bug. We want to document the 435 | # behaviour in that case anyway, as it might be surprising. 436 | context 'limited by policy scope on articles' do 437 | before do 438 | allow_operation( 439 | 'remove_to_many_relationship', 440 | source_record: article, 441 | related_records: [comments_to_remove.first, comments_to_remove.second], 442 | relationship_type: :comments 443 | ) 444 | end 445 | let(:policy_scope) { Article.where.not(id: article.id) } 446 | it { is_expected.to be_not_found } 447 | end 448 | end 449 | end 450 | 451 | describe 'DELETE /articles/:id/relationships/author' do 452 | subject(:last_response) { delete("/articles/#{article.external_id}/relationships/author") } 453 | 454 | let(:article) { articles(:article_with_author) } 455 | let(:policy_scope) { Article.all } 456 | 457 | context 'unauthorized for remove_to_one_relationship' do 458 | before { disallow_operation('remove_to_one_relationship', source_record: article, relationship_type: :author) } 459 | it { is_expected.to be_forbidden } 460 | end 461 | 462 | context 'authorized for remove_to_one_relationship' do 463 | before { allow_operation('remove_to_one_relationship', source_record: article, relationship_type: :author) } 464 | it { is_expected.to be_successful } 465 | 466 | # If this happens in real life, it's mostly a bug. We want to document the 467 | # behaviour in that case anyway, as it might be surprising. 468 | context 'limited by policy scope' do 469 | let(:policy_scope) { Article.where.not(id: article.id) } 470 | it { is_expected.to be_not_found } 471 | end 472 | end 473 | end 474 | end 475 | -------------------------------------------------------------------------------- /spec/requests/resource_operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'Resource operations', type: :request do 6 | include AuthorizationStubs 7 | fixtures :all 8 | 9 | let(:article) { Article.all.sample } 10 | let(:policy_scope) { Article.none } 11 | 12 | subject { last_response } 13 | let(:json_data) { JSON.parse(last_response.body)["data"] } 14 | 15 | before do 16 | allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve).and_return(policy_scope) 17 | end 18 | 19 | before do 20 | header 'Content-Type', 'application/vnd.api+json' 21 | end 22 | 23 | describe 'GET /articles' do 24 | subject(:last_response) { get('/articles') } 25 | 26 | context 'unauthorized for find' do 27 | before { disallow_operation('find', source_class: Article) } 28 | it { is_expected.to be_forbidden } 29 | end 30 | 31 | context 'authorized for find' do 32 | before { allow_operation('find', source_class: Article) } 33 | let(:policy_scope) { Article.where(id: article.id) } 34 | 35 | it { is_expected.to be_ok } 36 | 37 | it 'returns results limited by policy scope' do 38 | expect(json_data.length).to eq(1) 39 | expect(json_data.first["id"]).to eq(article.external_id) 40 | end 41 | end 42 | end 43 | 44 | describe 'GET /articles/:id' do 45 | subject(:last_response) { get("/articles/#{article.external_id}") } 46 | let(:policy_scope) { Article.all } 47 | 48 | context 'unauthorized for show' do 49 | before { disallow_operation('show', source_record: article) } 50 | 51 | context 'not limited by policy scope' do 52 | it { is_expected.to be_forbidden } 53 | end 54 | 55 | context 'limited by policy scope' do 56 | let(:policy_scope) { Article.where.not(id: article.id) } 57 | it { is_expected.to be_not_found } 58 | end 59 | end 60 | 61 | context 'authorized for show' do 62 | before { allow_operation('show', source_record: article) } 63 | it { is_expected.to be_ok } 64 | 65 | # If this happens in real life, it's mostly a bug. We want to document the 66 | # behaviour in that case anyway, as it might be surprising. 67 | context 'limited by policy scope' do 68 | let(:policy_scope) { Article.where.not(id: article.id) } 69 | it { is_expected.to be_not_found } 70 | end 71 | end 72 | end 73 | 74 | describe 'POST /articles' do 75 | subject(:last_response) { post("/articles", json) } 76 | let(:json) do 77 | <<-JSON.strip_heredoc 78 | { 79 | "data": { 80 | "id": "external_id", 81 | "type": "articles" 82 | } 83 | } 84 | JSON 85 | end 86 | 87 | context 'unauthorized for create_resource' do 88 | before { disallow_operation('create_resource', source_class: Article, related_records_with_context: []) } 89 | it { is_expected.to be_forbidden } 90 | end 91 | 92 | context 'authorized for create_resource' do 93 | before { allow_operation('create_resource', source_class: Article, related_records_with_context: []) } 94 | it { is_expected.to be_successful } 95 | end 96 | end 97 | 98 | describe 'PATCH /articles/:id' do 99 | let(:json) do 100 | <<-JSON.strip_heredoc 101 | { 102 | "data": { 103 | "id": "#{article.external_id}", 104 | "type": "articles" 105 | } 106 | } 107 | JSON 108 | end 109 | 110 | subject(:last_response) { patch("/articles/#{article.external_id}", json) } 111 | let(:policy_scope) { Article.all } 112 | 113 | context 'authorized for replace_fields' do 114 | before { allow_operation('replace_fields', source_record: article, related_records_with_context: []) } 115 | it { is_expected.to be_successful } 116 | 117 | context 'limited by policy scope' do 118 | let(:policy_scope) { Article.where.not(id: article.id) } 119 | it { is_expected.to be_not_found } 120 | end 121 | end 122 | 123 | context 'unauthorized for replace_fields' do 124 | before { disallow_operation('replace_fields', source_record: article, related_records_with_context: []) } 125 | it { is_expected.to be_forbidden } 126 | 127 | context 'limited by policy scope' do 128 | let(:policy_scope) { Article.where.not(id: article.id) } 129 | it { is_expected.to be_not_found } 130 | end 131 | end 132 | end 133 | 134 | describe 'DELETE /articles/:id' do 135 | subject(:last_response) { delete("/articles/#{article.external_id}") } 136 | let(:policy_scope) { Article.all } 137 | 138 | context 'unauthorized for remove_resource' do 139 | before { disallow_operation('remove_resource', source_record: article) } 140 | 141 | context 'not limited by policy scope' do 142 | it { is_expected.to be_forbidden } 143 | end 144 | 145 | context 'limited by policy scope' do 146 | let(:policy_scope) { Article.where.not(id: article.id) } 147 | it { is_expected.to be_not_found } 148 | end 149 | end 150 | 151 | context 'authorized for remove_resource' do 152 | before { allow_operation('remove_resource', source_record: article) } 153 | it { is_expected.to be_successful } 154 | 155 | # If this happens in real life, it's mostly a bug. We want to document the 156 | # behaviour in that case anyway, as it might be surprising. 157 | context 'limited by policy scope' do 158 | let(:policy_scope) { Article.where.not(id: article.id) } 159 | it { is_expected.to be_not_found } 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/requests/tricky_operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Tricky operations', type: :request do 6 | include AuthorizationStubs 7 | fixtures :all 8 | 9 | let(:article) { Article.all.sample } 10 | let(:policy_scope) { Article.none } 11 | 12 | subject { last_response } 13 | let(:json_data) { JSON.parse(last_response.body)["data"] } 14 | 15 | before do 16 | allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve).and_return(policy_scope) 17 | end 18 | 19 | before do 20 | header 'Content-Type', 'application/vnd.api+json' 21 | end 22 | 23 | describe 'POST /comments (with relationships link to articles)' do 24 | subject(:last_response) { post("/comments", json) } 25 | let(:json) do 26 | <<-JSON.strip_heredoc 27 | { 28 | "data": { 29 | "type": "comments", 30 | "relationships": { 31 | "article": { 32 | "data": { 33 | "id": "#{article.external_id}", 34 | "type": "articles" 35 | } 36 | } 37 | } 38 | } 39 | } 40 | JSON 41 | end 42 | let(:related_records_with_context) do 43 | [{ 44 | relation_name: :article, 45 | relation_type: :to_one, 46 | records: article 47 | }] 48 | end 49 | 50 | context 'authorized for create_resource on Comment and newly associated article' do 51 | let(:policy_scope) { Article.where(id: article.id) } 52 | before { allow_operation('create_resource', source_class: Comment, related_records_with_context: related_records_with_context) } 53 | 54 | it { is_expected.to be_successful } 55 | end 56 | 57 | context 'unauthorized for create_resource on Comment and newly associated article' do 58 | let(:policy_scope) { Article.where(id: article.id) } 59 | before { disallow_operation('create_resource', source_class: Comment, related_records_with_context: related_records_with_context) } 60 | 61 | it { is_expected.to be_forbidden } 62 | 63 | context 'which is out of scope' do 64 | let(:policy_scope) { Article.none } 65 | 66 | it { is_expected.to be_not_found } 67 | end 68 | end 69 | end 70 | 71 | describe 'POST /articles (with relationships link to comments)' do 72 | let!(:new_comments) do 73 | Array.new(2) { Comment.create } 74 | end 75 | let(:related_records_with_context) do 76 | [{ 77 | relation_name: :comments, 78 | relation_type: :to_many, 79 | records: new_comments 80 | }] 81 | end 82 | let(:comments_policy_scope) { Comment.all } 83 | before do 84 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_policy_scope) 85 | end 86 | 87 | let(:json) do 88 | <<-JSON.strip_heredoc 89 | { 90 | "data": { 91 | "id": "new-article-id", 92 | "type": "articles", 93 | "relationships": { 94 | "comments": { 95 | "data": [ 96 | { "id": "#{new_comments[0].id}", "type": "comments" }, 97 | { "id": "#{new_comments[1].id}", "type": "comments" } 98 | ] 99 | } 100 | } 101 | } 102 | } 103 | JSON 104 | end 105 | subject(:last_response) { post("/articles", json) } 106 | 107 | context 'authorized for create_resource on Article and newly associated comments' do 108 | let(:policy_scope) { Article.where(id: "new-article-id") } 109 | before { allow_operation('create_resource', source_class: Article, related_records_with_context: related_records_with_context) } 110 | 111 | it { is_expected.to be_successful } 112 | end 113 | 114 | context 'unauthorized for create_resource on Article and newly associated comments' do 115 | let(:policy_scope) { Article.where(id: "new-article-id") } 116 | before { disallow_operation('create_resource', source_class: Article, related_records_with_context: related_records_with_context) } 117 | 118 | it { is_expected.to be_forbidden } 119 | end 120 | end 121 | 122 | describe 'POST /tags (with polymorphic relationship link to article)' do 123 | subject(:last_response) { post("/tags", json) } 124 | let(:json) do 125 | <<-JSON.strip_heredoc 126 | { 127 | "data": { 128 | "type": "tags", 129 | "relationships": { 130 | "taggable": { 131 | "data": { 132 | "id": "#{article.external_id}", 133 | "type": "articles" 134 | } 135 | } 136 | } 137 | } 138 | } 139 | JSON 140 | end 141 | 142 | let(:related_records_with_context) do 143 | [{ 144 | relation_name: :taggable, 145 | relation_type: :to_one, 146 | records: article 147 | }] 148 | end 149 | 150 | context 'authorized for create_resource on Tag and newly associated article' do 151 | let(:policy_scope) { Article.where(id: article.id) } 152 | before { allow_operation('create_resource', source_class: Tag, related_records_with_context: related_records_with_context) } 153 | 154 | it { is_expected.to be_successful } 155 | end 156 | 157 | context 'unauthorized for create_resource on Tag and newly associated article' do 158 | let(:policy_scope) { Article.where(id: article.id) } 159 | before { disallow_operation('create_resource', source_class: Tag, related_records_with_context: related_records_with_context) } 160 | 161 | it { is_expected.to be_forbidden } 162 | 163 | context 'which is out of scope' do 164 | let(:policy_scope) { Article.none } 165 | 166 | it { is_expected.to be_not_found } 167 | end 168 | end 169 | end 170 | 171 | describe 'PATCH /articles/:id (mass-modifying relationships)' do 172 | let!(:new_comments) do 173 | Array.new(2) { Comment.create } 174 | end 175 | let(:related_records_with_context) do 176 | [{ 177 | relation_name: :comments, 178 | relation_type: :to_many, 179 | records: new_comments 180 | }] 181 | end 182 | let(:policy_scope) { Article.where(id: article.id) } 183 | let(:comments_policy_scope) { Comment.all } 184 | before do 185 | allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(comments_policy_scope) 186 | end 187 | 188 | let(:json) do 189 | <<-JSON.strip_heredoc 190 | { 191 | "data": { 192 | "id": "#{article.external_id}", 193 | "type": "articles", 194 | "relationships": { 195 | "comments": { 196 | "data": [ 197 | { "type": "comments", "id": "#{new_comments.first.id}" }, 198 | { "type": "comments", "id": "#{new_comments.second.id}" } 199 | ] 200 | } 201 | } 202 | } 203 | } 204 | JSON 205 | end 206 | subject(:last_response) { patch("/articles/#{article.external_id}", json) } 207 | 208 | context 'authorized for replace_fields on article and all new records' do 209 | context 'not limited by Comments policy scope' do 210 | before { allow_operation('replace_fields', source_record: article, related_records_with_context: related_records_with_context) } 211 | it { is_expected.to be_successful } 212 | end 213 | 214 | context 'limited by Comments policy scope' do 215 | let(:comments_policy_scope) { Comment.where("id NOT IN (?)", new_comments.map(&:id)) } 216 | let(:related_records_with_context) do 217 | [{ 218 | relation_name: :comments, 219 | relation_type: :to_many, 220 | records: new_comments 221 | }] 222 | end 223 | before { disallow_operation('replace_fields', source_record: article, related_records_with_context: related_records_with_context) } 224 | 225 | it { is_expected.to be_not_found } 226 | end 227 | end 228 | 229 | context 'unauthorized for replace_fields on article and all new records' do 230 | before { disallow_operation('replace_fields', source_record: article, related_records_with_context: related_records_with_context) } 231 | 232 | it { is_expected.to be_forbidden } 233 | end 234 | end 235 | 236 | describe 'PATCH /articles/:id (nullifying to-one relationship)' do 237 | let(:article) { articles(:article_with_author) } 238 | let(:json) do 239 | <<-JSON.strip_heredoc 240 | { 241 | "data": { 242 | "id": "#{article.external_id}", 243 | "type": "articles", 244 | "relationships": { "author": null } 245 | } 246 | } 247 | JSON 248 | end 249 | let(:policy_scope) { Article.all } 250 | subject(:last_response) { patch("/articles/#{article.external_id}", json) } 251 | 252 | before do 253 | allow_operation( 254 | 'replace_fields', 255 | source_record: article, 256 | related_records_with_context: [{ relation_type: :to_one, relation_name: :author, records: nil }] 257 | ) 258 | end 259 | 260 | it { is_expected.to be_successful } 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | 5 | # Configure Rails Environment 6 | ENV["RAILS_ENV"] = "test" 7 | 8 | require File.expand_path('dummy/config/environment.rb', __dir__) 9 | ActiveRecord::Migrator.migrations_paths = [File.expand_path('dummy/db/migrate', __dir__)] 10 | 11 | ActiveRecord::Migration.maintain_test_schema! 12 | 13 | require "pry" 14 | require "rspec/rails" 15 | 16 | Dir[File.expand_path('support/**/*.rb', __dir__)].sort.each { |f| require f } 17 | 18 | RSpec.configure do |config| 19 | config.include Rack::Test::Methods 20 | 21 | config.fixture_path = File.expand_path('fixtures', __dir__) 22 | 23 | config.use_transactional_fixtures = true 24 | 25 | config.example_status_persistence_file_path = 26 | File.expand_path('../tmp/rspec-example-statuses.txt', __dir__) 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/authorization_stubs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AuthorizationStubs 4 | AUTHORIZER_CLASS = JSONAPI::Authorization::DefaultPunditAuthorizer 5 | 6 | def allow_operation(operation, authorizer: instance_double(AUTHORIZER_CLASS), **kwargs) 7 | allow(authorizer).to receive(operation).with(**kwargs).and_return(nil) 8 | 9 | allow(AUTHORIZER_CLASS).to receive(:new).with(context: kind_of(Hash)).and_return(authorizer) 10 | authorizer 11 | end 12 | 13 | def disallow_operation(operation, authorizer: instance_double(AUTHORIZER_CLASS), **kwargs) 14 | allow(authorizer).to receive(operation).with(**kwargs).and_raise(Pundit::NotAuthorizedError) 15 | 16 | allow(AUTHORIZER_CLASS).to receive(:new).with(context: kind_of(Hash)).and_return(authorizer) 17 | authorizer 18 | end 19 | 20 | def allow_operations(operation, operation_args) 21 | authorizer = instance_double(AUTHORIZER_CLASS) 22 | operation_args.each do |args| 23 | allow(authorizer).to receive(operation).with(*args).and_return(nil) 24 | end 25 | 26 | allow(AUTHORIZER_CLASS).to receive(:new).with(context: kind_of(Hash)).and_return(authorizer) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/support/custom_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add better debuggability to be_forbidden failures 4 | RSpec::Matchers.define :be_forbidden do 5 | match(&:forbidden?) 6 | 7 | failure_message do |actual| 8 | debug_text_for_failure('forbidden', response: actual, last_request: last_request) 9 | end 10 | end 11 | 12 | # Add better debuggability to be_not_found failures 13 | RSpec::Matchers.define :be_not_found do 14 | match(&:not_found?) 15 | 16 | failure_message do |actual| 17 | debug_text_for_failure('not_found', response: actual, last_request: last_request) 18 | end 19 | end 20 | 21 | # Add better debuggability to be_unprocessable failures 22 | RSpec::Matchers.define :be_unprocessable do 23 | match(&:unprocessable?) 24 | 25 | failure_message do |actual| 26 | debug_text_for_failure('unprocessable', response: actual, last_request: last_request) 27 | end 28 | end 29 | 30 | # Add better debuggability to be_successful failures 31 | RSpec::Matchers.define :be_successful do 32 | match(&:successful?) 33 | 34 | failure_message do |actual| 35 | debug_text_for_failure('successful', response: actual, last_request: last_request) 36 | end 37 | end 38 | 39 | # Add better debuggability to be_ok failures 40 | RSpec::Matchers.define :be_ok do 41 | match(&:ok?) 42 | 43 | failure_message do |actual| 44 | debug_text_for_failure('ok', response: actual, last_request: last_request) 45 | end 46 | end 47 | 48 | def debug_text_for_failure(expected, response:, last_request:) 49 | debug_text = "expected response to be #{expected} but HTTP code was #{response.status}." 50 | debug_text += " Last request was #{last_request.request_method} to #{last_request.fullpath}" 51 | debug_text += " with body:\n#{last_request.body.read}" unless last_request.get? 52 | debug_text += "\nResponse body was:\n#{response.body}" 53 | debug_text 54 | end 55 | -------------------------------------------------------------------------------- /spec/support/pundit_stubs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PunditStubs 4 | def allow_action(record, action) 5 | policy = ::Pundit::PolicyFinder.new(record).policy 6 | allow(policy).to( 7 | receive(:new).with(any_args, record) { instance_double(policy, action => true) } 8 | ) 9 | end 10 | 11 | def disallow_action(record, action) 12 | policy = ::Pundit::PolicyFinder.new(record).policy 13 | allow(policy).to( 14 | receive(:new).with(any_args, record) { instance_double(policy, action => false) } 15 | ) 16 | end 17 | 18 | def stub_policy_actions(record, actions_and_return_values) 19 | policy = ::Pundit::PolicyFinder.new(record).policy 20 | allow(policy).to( 21 | receive(:new).with(any_args, record) do 22 | instance_double(policy).tap do |policy_double| 23 | actions_and_return_values.each do |action, is_allowed| 24 | allow(policy_double).to receive(action).and_return(is_allowed) 25 | end 26 | end 27 | end 28 | ) 29 | end 30 | end 31 | --------------------------------------------------------------------------------