├── .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 | [](https://travis-ci.com/venuu/jsonapi-authorization) [](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 |
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 |
--------------------------------------------------------------------------------