Skip to content

Commit 0708aaf

Browse files
brianswkovalscion
authored andcommitted
Check for authorization against related records (#119)
* AuthorizingProcessor#related_models_with_context should not use the scope * Remove unused AuthorizingProcessor#related_models * Check all related records in authorize_remove_to_many_relationships * Return 404 instead of 403 for records out of scope * Style fixes * Optimization for Array#size * Comment to clarify changes to mocks * Better array matching in specs
1 parent 5873c11 commit 0708aaf

File tree

4 files changed

+195
-63
lines changed

4 files changed

+195
-63
lines changed

lib/jsonapi/authorization/authorizing_processor.rb

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ def authorize_remove_to_many_relationships
219219

220220
related_records = related_resources.map(&:_model)
221221

222+
if related_records.size != params[:associated_keys].uniq.size
223+
fail JSONAPI::Exceptions::RecordNotFound, params[:associated_keys]
224+
end
225+
222226
authorizer.remove_to_many_relationship(
223227
source_record: source_record,
224228
related_records: related_records,
@@ -298,25 +302,6 @@ def model_class_for_relationship(assoc_name)
298302
resource_class_for_relationship(assoc_name)._model_class
299303
end
300304

301-
def related_models
302-
data = params[:data]
303-
return [] if data.nil?
304-
305-
[:to_one, :to_many].flat_map do |rel_type|
306-
data[rel_type].flat_map do |assoc_name, assoc_value|
307-
case assoc_value
308-
when Hash # polymorphic relationship
309-
resource_class = @resource_klass.resource_for(assoc_value[:type].to_s)
310-
resource_class.find_by_key(assoc_value[:id], context: context)._model
311-
else
312-
resource_class = resource_class_for_relationship(assoc_name)
313-
primary_key = resource_class._primary_key
314-
resource_class._model_class.where(primary_key => assoc_value)
315-
end
316-
end
317-
end
318-
end
319-
320305
def related_models_with_context
321306
data = params[:data]
322307
return { relationship: nil, relation_name: nil, records: nil } if data.nil?
@@ -330,12 +315,15 @@ def related_models_with_context
330315
when Hash # polymorphic relationship
331316
resource_class = @resource_klass.resource_for(assoc_value[:type].to_s)
332317
resource_class.find_by_key(assoc_value[:id], context: context)._model
333-
when Array
334-
resource_class = resource_class_for_relationship(assoc_name)
335-
resource_class.find_by_keys(assoc_value, context: context).map(&:_model)
336318
else
337319
resource_class = resource_class_for_relationship(assoc_name)
338-
resource_class.find_by_key(assoc_value, context: context)._model
320+
resources = resource_class.find_by_keys(assoc_value, context: context)
321+
resources.map(&:_model).tap do |scoped_records|
322+
related_ids = Array.wrap(assoc_value).uniq
323+
if scoped_records.count != related_ids.count
324+
fail JSONAPI::Exceptions::RecordNotFound, related_ids
325+
end
326+
end
339327
end
340328

341329
{

spec/requests/included_resources_spec.rb

Lines changed: 166 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@
77
subject { last_response }
88
let(:json_included) { JSON.parse(last_response.body)['included'] }
99

10-
let(:comments_policy_scope) { Comment.none }
10+
let(:comments_policy_scope) { Comment.all }
1111
let(:article_policy_scope) { Article.all }
1212
let(:user_policy_scope) { User.all }
1313

14+
# Take the stubbed scope and call merge(policy_scope.scope.all) so that the original
15+
# scope's conditions are not lost. Without it, the stub will always return all records
16+
# the user has access to regardless of context.
1417
before do
15-
allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve).and_return(
16-
article_policy_scope
17-
)
18-
allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve).and_return(
19-
comments_policy_scope
20-
)
21-
allow_any_instance_of(UserPolicy::Scope).to receive(:resolve).and_return(
22-
user_policy_scope
23-
)
18+
allow_any_instance_of(ArticlePolicy::Scope).to receive(:resolve) do |policy_scope|
19+
article_policy_scope.merge(policy_scope.scope.all)
20+
end
21+
allow_any_instance_of(CommentPolicy::Scope).to receive(:resolve) do |policy_scope|
22+
comments_policy_scope.merge(policy_scope.scope.all)
23+
end
24+
allow_any_instance_of(UserPolicy::Scope).to receive(:resolve) do |policy_scope|
25+
user_policy_scope.merge(policy_scope.scope.all)
26+
end
2427
end
2528

2629
before do
@@ -31,8 +34,6 @@
3134
describe 'one-level deep has_many relationship' do
3235
let(:include_query) { 'comments' }
3336

34-
let(:comments_policy_scope) { Comment.all }
35-
3637
context 'unauthorized for include_has_many_resource for Comment' do
3738
before {
3839
disallow_operation(
@@ -58,11 +59,11 @@
5859

5960
it { is_expected.to be_successful }
6061

61-
let(:comments_policy_scope) { Comment.limit(1) }
62-
63-
it 'includes only comments allowed by policy scope' do
64-
expect(json_included.length).to eq(1)
65-
expect(json_included.first["id"]).to eq(comments_policy_scope.first.id.to_s)
62+
it 'includes only comments allowed by policy scope and associated with the article' do
63+
expect(json_included.length).to eq(article.comments.count)
64+
expect(
65+
json_included.map { |included| included["id"].to_i }
66+
).to match_array(article.comments.map(&:id))
6667
end
6768
end
6869
end
@@ -104,7 +105,6 @@
104105

105106
describe 'multiple one-level deep relationships' do
106107
let(:include_query) { 'author,comments' }
107-
let(:comments_policy_scope) { Comment.all }
108108

109109
context 'unauthorized for include_has_one_resource for article.author' do
110110
before do
@@ -136,12 +136,12 @@
136136

137137
it { is_expected.to be_successful }
138138

139-
let(:comments_policy_scope) { Comment.limit(1) }
140-
141-
it 'includes only comments allowed by policy scope' do
139+
it 'includes only comments allowed by policy scope and associated with the article' do
142140
json_comments = json_included.select { |item| item['type'] == 'comments' }
143-
expect(json_comments.length).to eq(comments_policy_scope.length)
144-
expect(json_comments.map { |i| i['id'] }).to eq(comments_policy_scope.pluck(:id).map(&:to_s))
141+
expect(json_comments.length).to eq(article.comments.count)
142+
expect(
143+
json_comments.map { |i| i['id'] }
144+
).to match_array(article.comments.pluck(:id).map(&:to_s))
145145
end
146146

147147
it 'includes the associated author resource' do
@@ -181,20 +181,18 @@
181181

182182
it { is_expected.to be_successful }
183183

184-
let(:comments_policy_scope) { Comment.all }
185-
186184
it 'includes the first level resource' do
187185
json_users = json_included.select { |item| item['type'] == 'users' }
188186
expect(json_users).to include(a_hash_including('id' => article.author.id.to_s))
189187
end
190188

191189
describe 'second level resources' do
192-
let(:comments_policy_scope) { Comment.limit(1) }
193-
194190
it 'includes only resources allowed by policy scope' do
195191
second_level_items = json_included.select { |item| item['type'] == 'comments' }
196-
expect(second_level_items.length).to eq(comments_policy_scope.length)
197-
expect(second_level_items.map { |i| i['id'] }).to eq(comments_policy_scope.pluck(:id).map(&:to_s))
192+
expect(second_level_items.length).to eq(article.author.comments.count)
193+
expect(
194+
second_level_items.map { |i| i['id'] }
195+
).to match_array(article.author.comments.pluck(:id).map(&:to_s))
198196
end
199197
end
200198
end
@@ -226,6 +224,137 @@
226224
end
227225
end
228226

227+
shared_examples_for :scope_limited_directive_tests do
228+
describe 'one-level deep has_many relationship' do
229+
let(:comments_policy_scope) { Comment.where(id: article.comments.first.id) }
230+
let(:include_query) { 'comments' }
231+
232+
context 'authorized for include_has_many_resource for Comment' do
233+
before {
234+
allow_operation(
235+
'include_has_many_resource',
236+
source_record: an_instance_of(Article),
237+
record_class: Comment,
238+
authorizer: chained_authorizer
239+
)
240+
}
241+
242+
it { is_expected.to be_successful }
243+
244+
it 'includes only comments allowed by policy scope' do
245+
expect(json_included.length).to eq(comments_policy_scope.length)
246+
expect(json_included.first["id"]).to eq(comments_policy_scope.first.id.to_s)
247+
end
248+
end
249+
end
250+
251+
describe 'multiple one-level deep relationships' do
252+
let(:include_query) { 'author,comments' }
253+
let(:comments_policy_scope) { Comment.where(id: article.comments.first.id) }
254+
255+
context 'authorized for both operations' do
256+
before do
257+
allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer)
258+
allow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Comment, authorizer: chained_authorizer)
259+
end
260+
261+
it { is_expected.to be_successful }
262+
263+
it 'includes only comments allowed by policy scope and associated with the article' do
264+
json_comments = json_included.select { |item| item['type'] == 'comments' }
265+
expect(json_comments.length).to eq(comments_policy_scope.length)
266+
expect(
267+
json_comments.map { |i| i['id'] }
268+
).to match_array(comments_policy_scope.pluck(:id).map(&:to_s))
269+
end
270+
271+
it 'includes the associated author resource' do
272+
json_users = json_included.select { |item| item['type'] == 'users' }
273+
expect(json_users).to include(a_hash_including('id' => article.author.id.to_s))
274+
end
275+
end
276+
end
277+
278+
describe 'a deep relationship' do
279+
let(:include_query) { 'author.comments' }
280+
let(:comments_policy_scope) { Comment.where(id: article.author.comments.first.id) }
281+
282+
context 'authorized for first relationship' do
283+
before { allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) }
284+
285+
context 'authorized for second relationship' do
286+
before { allow_operation('include_has_many_resource', source_record: an_instance_of(User), record_class: Comment, authorizer: chained_authorizer) }
287+
288+
it { is_expected.to be_successful }
289+
290+
it 'includes the first level resource' do
291+
json_users = json_included.select { |item| item['type'] == 'users' }
292+
expect(json_users).to include(a_hash_including('id' => article.author.id.to_s))
293+
end
294+
295+
describe 'second level resources' do
296+
it 'includes only resources allowed by policy scope' do
297+
second_level_items = json_included.select { |item| item['type'] == 'comments' }
298+
expect(second_level_items.length).to eq(comments_policy_scope.length)
299+
expect(
300+
second_level_items.map { |i| i['id'] }
301+
).to match_array(comments_policy_scope.pluck(:id).map(&:to_s))
302+
end
303+
end
304+
end
305+
end
306+
end
307+
end
308+
309+
shared_examples_for :scope_limited_directive_test_modify_relationships do
310+
describe 'one-level deep has_many relationship' do
311+
let(:comments_policy_scope) { Comment.where(id: existing_comments.first.id) }
312+
let(:include_query) { 'comments' }
313+
314+
context 'authorized for include_has_many_resource for Comment' do
315+
before {
316+
allow_operation(
317+
'include_has_many_resource',
318+
source_record: an_instance_of(Article),
319+
record_class: Comment,
320+
authorizer: chained_authorizer
321+
)
322+
}
323+
324+
it { is_expected.to be_not_found }
325+
end
326+
end
327+
328+
describe 'multiple one-level deep relationships' do
329+
let(:include_query) { 'author,comments' }
330+
let(:comments_policy_scope) { Comment.where(id: existing_comments.first.id) }
331+
332+
context 'authorized for both operations' do
333+
before do
334+
allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer)
335+
allow_operation('include_has_many_resource', source_record: an_instance_of(Article), record_class: Comment, authorizer: chained_authorizer)
336+
end
337+
338+
it { is_expected.to be_not_found }
339+
end
340+
end
341+
342+
describe 'a deep relationship' do
343+
let(:include_query) { 'author.comments' }
344+
let(:comments_policy_scope) { Comment.where(id: existing_author.comments.first.id) }
345+
346+
context 'authorized for first relationship' do
347+
before { allow_operation('include_has_one_resource', source_record: an_instance_of(Article), related_record: an_instance_of(User), authorizer: chained_authorizer) }
348+
349+
context 'authorized for second relationship' do
350+
before { allow_operation('include_has_many_resource', source_record: an_instance_of(User), record_class: Comment, authorizer: chained_authorizer) }
351+
352+
it { is_expected.to be_not_found }
353+
end
354+
end
355+
end
356+
end
357+
229358
describe 'GET /articles' do
230359
subject(:last_response) { get("/articles?include=#{include_query}") }
231360
let!(:chained_authorizer) { allow_operation('find', source_class: Article) }
@@ -244,6 +373,7 @@
244373

245374
# TODO: Test properly with multiple articles, not just one.
246375
include_examples :include_directive_tests
376+
include_examples :scope_limited_directive_tests
247377
end
248378

249379
describe 'GET /articles/:id' do
@@ -261,6 +391,7 @@
261391
let!(:chained_authorizer) { allow_operation('show', source_record: article) }
262392

263393
include_examples :include_directive_tests
394+
include_examples :scope_limited_directive_tests
264395
end
265396

266397
describe 'PATCH /articles/:id' do
@@ -290,6 +421,7 @@
290421
let!(:chained_authorizer) { allow_operation('replace_fields', source_record: article, related_records_with_context: []) }
291422

292423
include_examples :include_directive_tests
424+
include_examples :scope_limited_directive_tests
293425

294426
context 'the request has already failed validations' do
295427
let(:include_query) { 'author.comments' }
@@ -315,7 +447,7 @@
315447
{
316448
relation_type: :to_one,
317449
relation_name: :author,
318-
records: existing_author
450+
records: [existing_author]
319451
},
320452
{
321453
relation_type: :to_many,
@@ -325,7 +457,7 @@
325457
# down in the other specs.
326458
#
327459
# This is fine, because we test resource create relationships with specific matcher
328-
records: kind_of(Array)
460+
records: kind_of(Enumerable)
329461
}
330462
]
331463
end
@@ -367,6 +499,7 @@
367499
end
368500

369501
include_examples :include_directive_tests
502+
include_examples :scope_limited_directive_test_modify_relationships
370503

371504
context 'the request has already failed validations' do
372505
let(:include_query) { 'author.comments' }
@@ -395,6 +528,7 @@
395528
let!(:chained_authorizer) { allow_operation('show_related_resources', source_record: article, related_record_class: article.class) }
396529

397530
include_examples :include_directive_tests
531+
include_examples :scope_limited_directive_tests
398532
end
399533

400534
describe 'GET /articles/:id/article' do
@@ -412,5 +546,6 @@
412546
let!(:chained_authorizer) { allow_operation('show_related_resource', source_record: article, related_record: article) }
413547

414548
include_examples :include_directive_tests
549+
include_examples :scope_limited_directive_tests
415550
end
416551
end

spec/requests/relationship_operations_spec.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -423,12 +423,10 @@
423423
context 'limited by policy scope on comments' do
424424
let(:comments_scope) { Comment.none }
425425
before do
426-
allow_operation('remove_to_many_relationship', source_record: article, related_records: [], relationship_type: :comments)
426+
disallow_operation('remove_to_many_relationship', source_record: article, related_records: comments_to_remove, relationship_type: :comments)
427427
end
428428

429-
# This succeeds because the request isn't actually able to try removing any comments
430-
# due to the comments-to-be-removed being an empty array
431-
it { is_expected.to be_successful }
429+
it { is_expected.to be_not_found }
432430
end
433431

434432
# If this happens in real life, it's mostly a bug. We want to document the

0 commit comments

Comments
 (0)