Skip to content

Commit df502e1

Browse files
authored
feat(scope): validate scope context on list/count request (#361)
1 parent 144bbfd commit df502e1

File tree

4 files changed

+319
-3
lines changed

4 files changed

+319
-3
lines changed

app/controllers/forest_liana/resources_controller.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ def index
2222
checker = ForestLiana::PermissionsChecker.new(@resource, 'searchToEdit', @rendering_id)
2323
return head :forbidden unless checker.is_authorized?
2424
else
25-
checker = ForestLiana::PermissionsChecker.new(@resource, 'list', @rendering_id)
25+
checker = ForestLiana::PermissionsChecker.new(
26+
@resource,
27+
'list',
28+
@rendering_id,
29+
nil,
30+
get_collection_list_permission_info(forest_user, request)
31+
)
2632
return head :forbidden unless checker.is_authorized?
2733
end
2834

@@ -51,7 +57,13 @@ def index
5157

5258
def count
5359
begin
54-
checker = ForestLiana::PermissionsChecker.new(@resource, 'list', @rendering_id)
60+
checker = ForestLiana::PermissionsChecker.new(
61+
@resource,
62+
'list',
63+
@rendering_id,
64+
nil,
65+
get_collection_list_permission_info(forest_user, request)
66+
)
5567
return head :forbidden unless checker.is_authorized?
5668

5769
getter = ForestLiana::ResourcesGetter.new(@resource, params)
@@ -232,5 +244,15 @@ def get_collection
232244
collection_name = ForestLiana.name_for(@resource)
233245
@collection ||= ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }
234246
end
247+
248+
# NOTICE: Return a formatted object containing the request condition filters and
249+
# the user id used by the scope validator class to validate if scope is
250+
# in request
251+
def get_collection_list_permission_info(user, collection_list_request)
252+
{
253+
user_id: user['id'],
254+
filters: collection_list_request[:filters],
255+
}
256+
end
235257
end
236258
end

app/services/forest_liana/permissions_checker.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ class PermissionsChecker
33
@@permissions_per_rendering = Hash.new
44
@@expiration_in_seconds = (ENV['FOREST_PERMISSIONS_EXPIRATION_IN_SECONDS'] || 3600).to_i
55

6-
def initialize(resource, permission_name, rendering_id, smart_action_parameters = nil)
6+
def initialize(resource, permission_name, rendering_id, smart_action_parameters = nil, collection_list_parameters = nil)
77
@collection_name = ForestLiana.name_for(resource)
88
@permission_name = permission_name
99
@rendering_id = rendering_id
1010
@smart_action_parameters = smart_action_parameters
11+
@collection_list_parameters = collection_list_parameters
1112
end
1213

1314
def is_authorized?
@@ -46,12 +47,23 @@ def smart_action_allowed?(smart_actions_permissions)
4647
return @allowed && (@users.nil?|| @users.include?(@user_id.to_i));
4748
end
4849

50+
def collection_list_allowed?(scope_permissions)
51+
return ForestLiana::ScopeValidator.new(
52+
scope_permissions['filter'],
53+
scope_permissions['dynamicScopesValues']['users']
54+
).is_scope_in_request?(@collection_list_parameters)
55+
end
56+
4957
def is_allowed?
5058
permissions = get_permissions
5159
if permissions && permissions[@collection_name] &&
5260
permissions[@collection_name]['collection']
5361
if @permission_name === 'actions'
5462
return smart_action_allowed?(permissions[@collection_name]['actions'])
63+
# NOTICE: Permissions[@collection_name]['scope'] will either contains conditions filter and
64+
# dynamic user values definition, or null for collection that does not use scopes
65+
elsif @permission_name === 'list' and permissions[@collection_name]['scope']
66+
return collection_list_allowed?(permissions[@collection_name]['scope'])
5567
else
5668
return permissions[@collection_name]['collection'][@permission_name]
5769
end
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
module ForestLiana
2+
class ScopeValidator
3+
def initialize(scope_permissions, users_variable_values)
4+
@scope_filters = scope_permissions
5+
@users_variable_values = users_variable_values
6+
end
7+
8+
def is_scope_in_request?(scope_request)
9+
begin
10+
filters = JSON.parse(scope_request[:filters])
11+
rescue JSON::ParserError
12+
raise ForestLiana::Errors::HTTP422Error.new('Invalid filters JSON format')
13+
end
14+
@computed_scope = compute_condition_filters_from_scope(scope_request[:user_id])
15+
16+
# NOTICE: Perfom a travel in the request condition filters tree to find the scope
17+
tagged_scope_filters = get_scope_found_in_request(filters)
18+
19+
# NOTICE: Permission system always send an aggregator even if there is only one condition
20+
# In that case, if the condition is valid, then request was not edited
21+
return !tagged_scope_filters.nil? if @scope_filters['conditions'].length == 1
22+
23+
# NOTICE: If there is more than one condition, do a final validation on the condition filters
24+
return tagged_scope_filters != nil &&
25+
tagged_scope_filters[:aggregator] == @scope_filters['aggregator'] &&
26+
tagged_scope_filters[:conditions] &&
27+
tagged_scope_filters[:conditions].length == @scope_filters['conditions'].length
28+
end
29+
30+
private
31+
32+
def compute_condition_filters_from_scope(user_id)
33+
computed_condition_filters = @scope_filters.clone
34+
computed_condition_filters['conditions'].each do |condition|
35+
if condition.include?('value') &&
36+
!condition['value'].nil? &&
37+
condition['value'].start_with?('$') &&
38+
@users_variable_values.include?(user_id)
39+
condition['value'] = @users_variable_values[user_id][condition['value']]
40+
end
41+
end
42+
return computed_condition_filters
43+
end
44+
45+
def get_scope_found_in_request(filters)
46+
return nil unless filters
47+
return search_scope_aggregation(filters)
48+
end
49+
50+
def search_scope_aggregation(node)
51+
ensure_valid_aggregation(node)
52+
53+
return is_scope_condition?(node) unless node['aggregator']
54+
55+
# NOTICE: Remove conditions that are not from the scope
56+
filtered_conditions = node['conditions'].map { |condition|
57+
search_scope_aggregation(condition)
58+
}.select { |condition|
59+
condition
60+
}
61+
62+
# NOTICE: If there is only one condition filter left and its current aggregator is
63+
# an "and", this condition filter is the searched scope
64+
if (filtered_conditions.length == 1 &&
65+
filtered_conditions.first.is_a?(Hash) &&
66+
filtered_conditions.first.include?(:aggregator) &&
67+
node['aggregator'] == 'and')
68+
return filtered_conditions.first
69+
end
70+
71+
# NOTICE: Otherwise, validate if the current node is the scope and return nil
72+
# if it's not
73+
return (filtered_conditions.length == @scope_filters['conditions'].length &&
74+
node['aggregator'] == @scope_filters['aggregator']) ?
75+
{ aggregator: node['aggregator'], conditions: filtered_conditions } :
76+
nil
77+
end
78+
79+
def is_scope_condition?(condition)
80+
ensure_valid_condition(condition)
81+
return @computed_scope['conditions'].include?(condition)
82+
end
83+
84+
def ensure_valid_aggregation(node)
85+
raise ForestLiana::Errors::HTTP422Error.new('Filters cannot be a raw value') unless node.is_a?(Hash)
86+
raise_empty_condition_in_filter_error if node.empty?
87+
end
88+
89+
def ensure_valid_condition(condition)
90+
raise_empty_condition_in_filter_error if condition.empty?
91+
raise ForestLiana::Errors::HTTP422Error.new('Condition cannot be a raw value') unless condition.is_a?(Hash)
92+
unless condition['field'].is_a?(String) and condition['operator'].is_a?(String)
93+
raise ForestLiana::Errors::HTTP422Error.new('Invalid condition format')
94+
end
95+
end
96+
end
97+
end
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
module ForestLiana
2+
class ScopeValidatorTest < ActiveSupport::TestCase
3+
test 'Request with aggregated condition filters should be allowed if it matches the scope exactly' do
4+
scope_validator = ForestLiana::ScopeValidator.new({
5+
'aggregator' => 'and',
6+
'conditions' => [
7+
{ 'field' => 'name', 'value' => 'john', 'operator' => 'equal' },
8+
{ 'field' => 'price', 'value' => '2500', 'operator' => 'equal' }
9+
]
10+
}, [])
11+
12+
allowed = scope_validator.is_scope_in_request?({
13+
user_id: '1',
14+
filters: JSON.generate({
15+
aggregator: 'and',
16+
conditions: [
17+
{ field: 'name', value: 'john', operator: 'equal' },
18+
{ field: 'price', value: '2500', operator: 'equal' }
19+
]
20+
})
21+
})
22+
assert allowed == true
23+
end
24+
25+
test 'Request with simple condition filter should be allowed if it matches the scope exactly' do
26+
scope_validator = ForestLiana::ScopeValidator.new({
27+
'aggregator' => 'and',
28+
'conditions' => [
29+
{ 'field' => 'field', 'value' => 'value', 'operator' => 'equal' }
30+
]
31+
}, [])
32+
allowed = scope_validator.is_scope_in_request?({
33+
user_id: '1',
34+
filters: JSON.generate({
35+
field: 'field', value: 'value', operator: 'equal'
36+
})
37+
})
38+
assert allowed == true
39+
end
40+
41+
test 'Request with multiples condition filters should be allowed if it contains the scope ' do
42+
scope_validator = ForestLiana::ScopeValidator.new({
43+
'aggregator' => 'and',
44+
'conditions' => [
45+
{ 'field' => 'name', 'value' => 'doe', 'operator' => 'equal' }
46+
]
47+
}, []
48+
)
49+
50+
allowed = scope_validator.is_scope_in_request?({
51+
user_id: '1',
52+
filters: JSON.generate({
53+
aggregator: 'and',
54+
conditions: [
55+
{ field: 'name', value: 'doe', operator: 'equal' },
56+
{ field: 'field2', value: 'value2', operator: 'equal' }
57+
]
58+
})
59+
})
60+
assert allowed == true
61+
end
62+
63+
test 'Request with dynamic user values should be allowed if it matches the scope exactly' do
64+
scope_validator = ForestLiana::ScopeValidator.new({
65+
'aggregator' => 'and',
66+
'conditions' => [
67+
{ 'field' => 'name', 'value' => '$currentUser.lastname', 'operator' => 'equal' }
68+
],
69+
}, {
70+
'1' => { '$currentUser.lastname' => 'john' }
71+
})
72+
73+
allowed = scope_validator.is_scope_in_request?({
74+
user_id: '1',
75+
filters: JSON.generate({
76+
'field' => 'name', 'value' => 'john', 'operator' => 'equal'
77+
})
78+
})
79+
assert allowed == true
80+
end
81+
82+
test 'Request with multiples aggregation and dynamic values should be allowed if it contains the scope' do
83+
scope_validator = ForestLiana::ScopeValidator.new({
84+
'aggregator' => 'or',
85+
'conditions' => [
86+
{ 'field' => 'price', 'value' => '2500', 'operator' => 'equal' },
87+
{ 'field' => 'name', 'value' => '$currentUser.lastname', 'operator' => 'equal' }
88+
]
89+
}, {
90+
'1' => { '$currentUser.lastname' => 'john' }
91+
})
92+
93+
allowed = scope_validator.is_scope_in_request?({
94+
user_id: '1',
95+
filters: JSON.generate({
96+
aggregator: 'and',
97+
conditions: [
98+
{ field: 'field', value: 'value', operator: 'equal' },
99+
{
100+
aggregator: 'or',
101+
conditions: [
102+
{ field: 'price', value: '2500', operator: 'equal' },
103+
{ field: 'name', value: 'john', operator: 'equal' }
104+
]
105+
}
106+
]
107+
})
108+
})
109+
assert allowed == true
110+
end
111+
112+
test 'Request that does not match the expect scope should not be allowed' do
113+
scope_validator = ForestLiana::ScopeValidator.new({
114+
'aggregator' => 'and',
115+
'conditions' => [
116+
{ 'field' => 'name', 'value' => 'john', 'operator' => 'equal' },
117+
{ 'field' => 'price', 'value' => '2500', 'operator' => 'equal' }
118+
]
119+
}, [])
120+
121+
allowed = scope_validator.is_scope_in_request?({
122+
user_id: '1',
123+
filters: JSON.generate({
124+
aggregator: 'and',
125+
conditions: [
126+
{ field: 'name', value: 'definitely_not_john', operator: 'equal' },
127+
{ field: 'price', value: '0', operator: 'equal' }
128+
]
129+
})
130+
})
131+
assert allowed == false
132+
end
133+
134+
test 'Request that are missing part of the scope should not be allowed' do
135+
scope_validator = ForestLiana::ScopeValidator.new({
136+
'aggregator' => 'and',
137+
'conditions' => [
138+
{ 'field' => 'name', 'value' => 'john', 'operator' => 'equal' },
139+
{ 'field' => 'price', 'value' => '2500', 'operator' => 'equal' }
140+
]
141+
}, [])
142+
143+
allowed = scope_validator.is_scope_in_request?({
144+
user_id: '1',
145+
filters: JSON.generate({
146+
aggregator: 'and',
147+
conditions: [
148+
{ field: 'name', value: 'john', operator: 'equal' },
149+
]
150+
})
151+
})
152+
assert allowed == false
153+
end
154+
155+
test 'Request that does not have a top aggregator being "and" should not be allowed' do
156+
scope_validator = ForestLiana::ScopeValidator.new({
157+
'aggregator' => 'and',
158+
'conditions' => [
159+
{ 'field' => 'price', 'value' => '2500', 'operator' => 'equal' },
160+
{ 'field' => 'name', 'value' => '$currentUser.lastname', 'operator' => 'equal' }
161+
]
162+
}, {
163+
'1' => { '$currentUser.lastname' => 'john' }
164+
})
165+
166+
allowed = scope_validator.is_scope_in_request?({
167+
user_id: '1',
168+
filters: JSON.generate({
169+
aggregator: 'or',
170+
conditions: [
171+
{ field: 'field', value: 'value', operator: 'equal' },
172+
{
173+
aggregator: 'and',
174+
conditions: [
175+
{ field: 'price', value: '2500', operator: 'equal' },
176+
{ field: 'name', value: 'john', operator: 'equal' }
177+
]
178+
}
179+
]
180+
})
181+
})
182+
assert allowed == false
183+
end
184+
end
185+
end

0 commit comments

Comments
 (0)