Skip to content

Commit 2014ab5

Browse files
authored
feat(security): authorised only allowed stats queries using permissions (#434)
* feat(security): authorized only allowed live queries using permissions * refactor: live query allowed * test(permissions-checker): add is_authorize live query tests * test(controller): add spec test to stats-controller * feat(security): stats parameters permissions * test: add test for stat with parameters permissions * refactor(stat_controller): similar blocks for permission check * fix(stat_controller): similar blocks for permission check * fix(stat_controller): before_action and before_filter implementation
1 parent 5041ec8 commit 2014ab5

File tree

6 files changed

+349
-10
lines changed

6 files changed

+349
-10
lines changed

app/controllers/forest_liana/stats_controller.rb

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
module ForestLiana
22
class StatsController < ForestLiana::ApplicationController
33
if Rails::VERSION::MAJOR < 4
4-
before_filter :find_resource, except: [:get_with_live_query]
4+
before_filter only: [:get] do
5+
find_resource()
6+
check_permission('statWithParameters')
7+
end
8+
before_filter only: [:get_with_live_query] do
9+
check_permission('liveQueries')
10+
end
511
else
6-
before_action :find_resource, except: [:get_with_live_query]
12+
before_action only: [:get] do
13+
find_resource()
14+
check_permission('statWithParameters')
15+
end
16+
before_action only: [:get_with_live_query] do
17+
check_permission('liveQueries')
18+
end
719
end
820

921
CHART_TYPE_VALUE = 'Value'
@@ -64,5 +76,38 @@ def find_resource
6476
render json: {status: 404}, status: :not_found, serializer: nil
6577
end
6678
end
79+
80+
def get_live_query_request_info
81+
params['query']
82+
end
83+
84+
def get_stat_parameter_request_info
85+
parameters = Rails::VERSION::MAJOR < 5 ? params.dup : params.permit(params.keys).to_h;
86+
87+
# Notice: Removes useless properties
88+
parameters.delete('timezone');
89+
parameters.delete('controller');
90+
parameters.delete('action');
91+
92+
return parameters;
93+
end
94+
95+
def check_permission(permission_name)
96+
begin
97+
query_request = permission_name == 'liveQueries' ? get_live_query_request_info : get_stat_parameter_request_info;
98+
checker = ForestLiana::PermissionsChecker.new(
99+
nil,
100+
permission_name,
101+
@rendering_id,
102+
user_id: forest_user['id'],
103+
query_request_info: query_request
104+
)
105+
106+
return head :forbidden unless checker.is_authorized?
107+
rescue => error
108+
FOREST_LOGGER.error "Stats execution error: #{error}"
109+
render serializer: nil, json: { status: 400 }, status: :bad_request
110+
end
111+
end
67112
end
68113
end

app/services/forest_liana/permissions_checker.rb

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ class PermissionsChecker
66
# TODO: handle cache scopes per rendering
77
@@expiration_in_seconds = (ENV['FOREST_PERMISSIONS_EXPIRATION_IN_SECONDS'] || 3600).to_i
88

9-
def initialize(resource, permission_name, rendering_id, user_id:, smart_action_request_info: nil, collection_list_parameters: nil)
10-
@user_id = user_id
11-
@collection_name = ForestLiana.name_for(resource)
9+
def initialize(resource, permission_name, rendering_id, user_id: nil, smart_action_request_info: nil, collection_list_parameters: nil, query_request_info: nil)
10+
11+
@collection_name = resource.present? ? ForestLiana.name_for(resource) : nil
1212
@permission_name = permission_name
1313
@rendering_id = rendering_id
14+
15+
@user_id = user_id
1416
@smart_action_request_info = smart_action_request_info
1517
@collection_list_parameters = collection_list_parameters
18+
@query_request_info = query_request_info
1619
end
1720

1821
def is_authorized?
@@ -48,6 +51,16 @@ def add_scopes_to_cache(permissions)
4851

4952
def is_allowed
5053
permissions = get_permissions_content
54+
55+
# NOTICE: check liveQueries permissions
56+
if @permission_name === 'liveQueries'
57+
return live_query_allowed?
58+
elsif @permission_name === 'statWithParameters'
59+
return stat_with_parameters_allowed?
60+
end
61+
62+
63+
5164
if permissions && permissions[@collection_name] &&
5265
permissions[@collection_name]['collection']
5366
if @permission_name === 'actions'
@@ -98,6 +111,16 @@ def get_permissions_content
98111
permissions && permissions['data'] && permissions['data']['collections']
99112
end
100113

114+
def get_live_query_permissions_content
115+
permissions = get_permissions
116+
permissions && permissions['stats'] && permissions['stats']['queries']
117+
end
118+
119+
def get_stat_with_parameters_content(statPermissionType)
120+
permissions = get_permissions
121+
permissions && permissions['stats'] && permissions['stats'][statPermissionType]
122+
end
123+
101124
def get_last_fetch
102125
permissions = get_permissions
103126
permissions && permissions['last_fetch']
@@ -138,6 +161,32 @@ def are_scopes_valid?(scope_permissions)
138161
).is_scope_in_request?(@collection_list_parameters)
139162
end
140163

164+
def live_query_allowed?
165+
live_queries_permissions = get_live_query_permissions_content
166+
167+
return false unless live_queries_permissions
168+
169+
# NOTICE: @query_request_info matching an existing live query
170+
return live_queries_permissions.include? @query_request_info
171+
end
172+
173+
def stat_with_parameters_allowed?
174+
permissionType = @query_request_info['type'].downcase + 's'
175+
pool_permissions = get_stat_with_parameters_content(permissionType)
176+
177+
return false unless pool_permissions
178+
179+
# NOTICE: equivalent to Object.values in js
180+
array_query_request_info = @query_request_info.values
181+
182+
# NOTICE: pool_permissions contains the @query_request_info
183+
# we use the intersection between statPermission and @query_request_info
184+
return pool_permissions.any? {
185+
|statPermission|
186+
(array_query_request_info & statPermission.values) == array_query_request_info;
187+
}
188+
end
189+
141190
def date_difference_in_seconds(date1, date2)
142191
(date1 - date2).to_i
143192
end

spec/requests/stats_spec.rb

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
require 'rails_helper'
2+
require 'json'
3+
4+
describe "Stats", type: :request do
5+
6+
token = JWT.encode({
7+
id: 38,
8+
9+
first_name: 'Michael',
10+
last_name: 'Kelso',
11+
team: 'Operations',
12+
rendering_id: 16,
13+
exp: Time.now.to_i + 2.weeks.to_i
14+
}, ForestLiana.auth_secret, 'HS256')
15+
16+
headers = {
17+
'Accept' => 'application/json',
18+
'Content-Type' => 'application/json',
19+
'Authorization' => "Bearer #{token}"
20+
}
21+
22+
let(:schema) {
23+
[
24+
ForestLiana::Model::Collection.new({
25+
name: 'Products',
26+
fields: [],
27+
actions: []
28+
})
29+
]
30+
}
31+
32+
before do
33+
allow(ForestLiana).to receive(:apimap).and_return(schema)
34+
end
35+
36+
before(:each) do
37+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
38+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
39+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
40+
41+
allow_any_instance_of(ForestLiana::PermissionsChecker).to receive(:is_authorized?) { true }
42+
43+
allow_any_instance_of(ForestLiana::ValueStatGetter).to receive(:perform) { true }
44+
allow_any_instance_of(ForestLiana::QueryStatGetter).to receive(:perform) { true }
45+
end
46+
47+
48+
49+
describe 'POST /stats/:collection' do
50+
params = { type: 'Value', collection: 'User', aggregate: 'Count' }
51+
52+
it 'should respond 200' do
53+
data = ForestLiana::Model::Stat.new(value: { countCurrent: 0, countPrevious: 0 })
54+
allow_any_instance_of(ForestLiana::ValueStatGetter).to receive(:record) { data }
55+
# NOTICE: bypass : find_resource error
56+
allow_any_instance_of(ForestLiana::StatsController).to receive(:find_resource) { true }
57+
allow(ForestLiana::QueryHelper).to receive(:get_one_association_names_symbol) { true }
58+
59+
post '/forest/stats/Products', params: JSON.dump(params), headers: headers
60+
expect(response.status).to eq(200)
61+
end
62+
63+
it 'should respond 401 with no headers' do
64+
post '/forest/stats/Products', params: JSON.dump(params)
65+
expect(response.status).to eq(401)
66+
end
67+
68+
it 'should respond 404 with non existing collection' do
69+
allow_any_instance_of(ForestLiana::ValueStatGetter).to receive(:record) { nil }
70+
71+
post '/forest/stats/NoCollection', params: {}, headers: headers
72+
expect(response.status).to eq(404)
73+
end
74+
75+
# it 'should respond 403 Forbidden' do
76+
# allow_any_instance_of(ForestLiana::PermissionsChecker).to receive(:is_authorized?) { false }
77+
78+
# post '/forest/stats/Products', params: JSON.dump(params), headers: headers
79+
# expect(response.status).to eq(403)
80+
# end
81+
end
82+
83+
describe 'POST /stats' do
84+
params = { query: 'SELECT COUNT(*) AS value FROM products;' }
85+
86+
it 'should respond 200' do
87+
data = ForestLiana::Model::Stat.new(value: { value: 0, objective: 0 })
88+
allow_any_instance_of(ForestLiana::QueryStatGetter).to receive(:record) { data }
89+
90+
post '/forest/stats', params: JSON.dump(params), headers: headers
91+
expect(response.status).to eq(200)
92+
end
93+
94+
it 'should respond 401 with no headers' do
95+
post '/forest/stats', params: JSON.dump(params)
96+
expect(response.status).to eq(401)
97+
end
98+
99+
it 'should respond 403 Forbidden' do
100+
allow_any_instance_of(ForestLiana::PermissionsChecker).to receive(:is_authorized?) { false }
101+
102+
post '/forest/stats', params: JSON.dump(params), headers: headers
103+
expect(response.status).to eq(403)
104+
end
105+
106+
it 'should respond 422 with unprocessable query' do
107+
allow_any_instance_of(ForestLiana::QueryStatGetter).to receive(:perform) { raise ForestLiana::Errors::LiveQueryError.new }
108+
109+
post '/forest/stats', params: JSON.dump(params), headers: headers
110+
expect(response.status).to eq(422)
111+
end
112+
end
113+
114+
end

spec/services/forest_liana/permissions_checker_acl_disabled_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ module ForestLiana
109109

110110
describe 'handling cache' do
111111
let(:collection_name) { 'all_rights_collection' }
112-
let(:fake_ressource) { nil }
112+
let(:fake_ressource) { collection_name }
113113
let(:default_rendering_id) { 1 }
114114

115115
context 'when calling twice the same permissions' do
@@ -191,7 +191,7 @@ module ForestLiana
191191

192192

193193
context 'scopes cache' do
194-
let(:fake_ressource) { nil }
194+
let(:fake_ressource) { collection_name }
195195
let(:rendering_id) { 1 }
196196
let(:collection_name) { 'custom' }
197197
let(:scope_permissions) { { rendering_id => { 'custom' => nil } } }
@@ -349,7 +349,7 @@ module ForestLiana
349349
describe '#is_authorized?' do
350350
# Resource is only used to retrieve the collection name as it's stubbed it does not
351351
# need to be defined
352-
let(:fake_ressource) { nil }
352+
let(:fake_ressource) { collection_name }
353353
let(:default_rendering_id) { nil }
354354
let(:api_permissions) { default_api_permissions }
355355
let(:collection_name) { 'all_rights_collection' }

spec/services/forest_liana/permissions_checker_acl_enabled_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ module ForestLiana
132132

133133
describe 'handling cache' do
134134
let(:collection_name) { 'all_rights_collection_boolean' }
135-
let(:fake_ressource) { nil }
135+
let(:fake_ressource) { collection_name }
136136
let(:default_rendering_id) { 1 }
137137

138138
context 'collections cache' do
@@ -396,7 +396,7 @@ module ForestLiana
396396
describe '#is_authorized?' do
397397
# Resource is only used to retrieve the collection name as it's stub it does not
398398
# need to be defined
399-
let(:fake_ressource) { nil }
399+
let(:fake_ressource) { collection_name }
400400
let(:default_rendering_id) { nil }
401401
let(:api_permissions) { default_api_permissions }
402402

0 commit comments

Comments
 (0)