forest_liana 9.22.5 → 9.22.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/services/forest_liana/ability/permission.rb +32 -7
- data/app/services/forest_liana/field_path.rb +5 -1
- data/app/services/forest_liana/search_query_builder.rb +6 -2
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/lib/forest_liana/collections/boat.rb +10 -0
- data/spec/dummy/lib/forest_liana/collections/captain.rb +10 -0
- data/spec/requests/smart_field_named_like_unexposed_relation_spec.rb +125 -0
- data/spec/requests/unresolved_polymorphic_relation_spec.rb +64 -0
- data/spec/services/forest_liana/ability/assert_can_read_query_fields_spec.rb +58 -0
- data/spec/services/forest_liana/ability/redact_fields_spec.rb +103 -7
- data/spec/services/forest_liana/search_query_builder_spec.rb +83 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1899aa8f55f2188189973046573741eb4492ad3e47c8bac900125d7468cc1845
|
|
4
|
+
data.tar.gz: 8fe15b7c2eaca28fa7c74ad66af35eecc7299dcea6f0671b8e07fa36f07529cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5c3b883ebdc5412f0a982b5cd12d23e361163471e5cbe39bc6e7ca956672f8a34ef06eb41300279ce0ac9ca79aab26f28377539e9ada69cf39f8716b17323d3
|
|
7
|
+
data.tar.gz: 320775ad84781e8b15a27b5cffa365975afa2da9e4d59317bf6bdf2cd9978951eac6b0671e989b6201203df37bc1450e349cd171a4eaf799d6f5a94a28ed56bc
|
|
@@ -205,7 +205,7 @@ module ForestLiana
|
|
|
205
205
|
if readable.call(entry[:owners][collection_key])
|
|
206
206
|
acc[collection_key] = entry[:field_names].join(',')
|
|
207
207
|
else
|
|
208
|
-
denied << denial_entry(collection_key, entry[:owners][collection_key], readable_collection_names) if named
|
|
208
|
+
denied << denial_entry(collection_key, entry[:owners][collection_key], readable_collection_names) if named && !FieldPath.unresolved?(entry[:owners][collection_key])
|
|
209
209
|
end
|
|
210
210
|
else
|
|
211
211
|
kept = entry[:field_names].select do |field_name|
|
|
@@ -216,7 +216,7 @@ module ForestLiana
|
|
|
216
216
|
# differs from root_name — prefix the message so it doesn't read as if 'field_name'
|
|
217
217
|
# were a bare field of the root.
|
|
218
218
|
display_path = collection_key == root_name ? field_name : "#{collection_key}:#{field_name}"
|
|
219
|
-
denied << denial_entry(field_name, entry[:owners][field_name], readable_collection_names, display_path) if named
|
|
219
|
+
denied << denial_entry(field_name, entry[:owners][field_name], readable_collection_names, display_path) if named && !FieldPath.unresolved?(entry[:owners][field_name])
|
|
220
220
|
false
|
|
221
221
|
end
|
|
222
222
|
end
|
|
@@ -335,18 +335,40 @@ module ForestLiana
|
|
|
335
335
|
# association, so FieldPath would otherwise resolve it to a column of +model+ itself — the
|
|
336
336
|
# collection its `reference` actually points to is checked instead, the same target
|
|
337
337
|
# `fields_per_model` already resolves a caller-named smart relation to.
|
|
338
|
-
def resolve_owner(model, field_name)
|
|
338
|
+
def resolve_owner(model, field_name, smart_field_fallback: true)
|
|
339
339
|
smart_field = smart_belongs_to_field(model, field_name)
|
|
340
340
|
|
|
341
341
|
return [smart_field[:reference].split('.').first] if smart_field
|
|
342
342
|
|
|
343
|
-
FieldPath.leaf_collection_names(model, field_name)
|
|
343
|
+
targets = FieldPath.leaf_collection_names(model, field_name)
|
|
344
|
+
return targets if targets.any? { |name| collection_exposed?(name) }
|
|
345
|
+
|
|
346
|
+
# No relation to an unexposed target reaches the caller, so a computed smart field of the same
|
|
347
|
+
# name is the only field this name can mean: its block reads the root row. A polymorphic
|
|
348
|
+
# relation is kept whatever its targets, and keeps its check.
|
|
349
|
+
return [ForestLiana.name_for(model)] if smart_field_fallback && only_computed_smart_field?(model, field_name, targets)
|
|
350
|
+
|
|
351
|
+
targets
|
|
344
352
|
end
|
|
345
353
|
|
|
346
354
|
def smart_belongs_to_field(model, field_name)
|
|
347
|
-
|
|
355
|
+
forest_collection_for(model)&.fields_smart_belongs_to&.find { |field| field[:field].to_s == field_name }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# SchemaAdapter drops a relation to an excluded model, but keeps one whose target is missing from
|
|
359
|
+
# the apimap otherwise (a table absent at boot, a secondary database unreachable): skipped alike.
|
|
360
|
+
# A polymorphic relation's reference names the association, never one of its targets, so it stays.
|
|
361
|
+
def only_computed_smart_field?(model, field_name, unexposed_targets)
|
|
362
|
+
fields = forest_collection_for(model)&.fields.to_a.select { |field| field[:field].to_s == field_name }
|
|
363
|
+
fields = fields.reject do |field|
|
|
364
|
+
!field[:is_virtual] && unexposed_targets.include?(field[:reference].to_s.split('.').first)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
fields.any? && fields.all? { |field| field[:is_virtual] && field[:reference].nil? && field[:integration].nil? }
|
|
368
|
+
end
|
|
348
369
|
|
|
349
|
-
|
|
370
|
+
def forest_collection_for(model)
|
|
371
|
+
ForestLiana.apimap.find { |collection| collection.name.to_s == ForestLiana.name_for(model) }
|
|
350
372
|
end
|
|
351
373
|
|
|
352
374
|
# No role can ever be granted `read` on a collection absent from the apimap — a denial
|
|
@@ -378,7 +400,10 @@ module ForestLiana
|
|
|
378
400
|
# partition (not split) so an empty or colon-only path resolves to '' (the root, pinned
|
|
379
401
|
# readable) instead of nil.
|
|
380
402
|
def query_target_collections(root_model, path)
|
|
381
|
-
|
|
403
|
+
head, separator, = path.partition(':')
|
|
404
|
+
# A traversal names the relation itself, never a smart field: a String has no sub-field, and
|
|
405
|
+
# what the query joins for it is the relation's table.
|
|
406
|
+
resolve_owner(root_model, head, smart_field_fallback: separator.empty?)
|
|
382
407
|
end
|
|
383
408
|
|
|
384
409
|
def query_usage(action, root_model, path)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module ForestLiana
|
|
2
2
|
class FieldPath
|
|
3
3
|
# An empty list (a polymorphic relation with no declared target) is read as denied by
|
|
4
|
-
# readable_leaves?, not as nothing to check.
|
|
4
|
+
# readable_leaves?, not as nothing to check: a query refuses it, a projection drops it.
|
|
5
5
|
#
|
|
6
6
|
# A prefix naming no relation raises rather than falling back to the root collection, which
|
|
7
7
|
# is pinned readable upstream — a fallback would turn "does not resolve" into "allowed".
|
|
@@ -30,6 +30,10 @@ module ForestLiana
|
|
|
30
30
|
collection_names.any? && collection_names.all? { |name| readable_collection_names.include?(name) }
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def self.unresolved?(collection_names)
|
|
34
|
+
collection_names.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
33
37
|
def self.leaf_label(collection_names)
|
|
34
38
|
return 'an unresolved polymorphic relation' if collection_names.empty?
|
|
35
39
|
|
|
@@ -15,6 +15,7 @@ module ForestLiana
|
|
|
15
15
|
@search = @params[:search].presence
|
|
16
16
|
@user = user
|
|
17
17
|
@filter_joins = []
|
|
18
|
+
@search_field_paths = []
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def perform(resource)
|
|
@@ -31,6 +32,7 @@ module ForestLiana
|
|
|
31
32
|
# already excludes itself on it and so can never be the reason @conditions_pushed is true.
|
|
32
33
|
@conditions_pushed = false
|
|
33
34
|
@lambda_contributed = false
|
|
35
|
+
@search_footprint_tracked = false
|
|
34
36
|
@records = search_param
|
|
35
37
|
|
|
36
38
|
caller_filter = @params[:filters].present? ? ForestLiana::ScopeManager.inject_context_variables(@params[:filters], @user) : nil
|
|
@@ -90,12 +92,13 @@ module ForestLiana
|
|
|
90
92
|
# hadn't run at all — this can't currently tell "ran and did nothing" apart from "ran and
|
|
91
93
|
# found real rows". Logged rather than silently discarded, since nothing else would ever
|
|
92
94
|
# surface it to whoever built the smart-search hook.
|
|
93
|
-
if @lambda_contributed && !@conditions_pushed && malformed_uuid_search?
|
|
95
|
+
if @search_footprint_tracked && @lambda_contributed && !@conditions_pushed && malformed_uuid_search?
|
|
94
96
|
FOREST_LOGGER.info "A smart-search lambda's result on the \"#{ForestLiana.name_for(root_model)}\" " \
|
|
95
97
|
"collection was discarded: the search term (#{@search.inspect}) is UUID-shaped but " \
|
|
96
98
|
'invalid, and no other condition constrained the query.'
|
|
97
99
|
end
|
|
98
|
-
|
|
100
|
+
# A host override of search_param that never calls super pushes no condition: its result is served as is.
|
|
101
|
+
@records = @records.none if @search_footprint_tracked && !@conditions_pushed && !(@lambda_contributed && !malformed_uuid_search?)
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
@records = sort_query
|
|
@@ -125,6 +128,7 @@ module ForestLiana
|
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
def search_param
|
|
131
|
+
@search_footprint_tracked = true
|
|
128
132
|
@search_field_paths = []
|
|
129
133
|
|
|
130
134
|
if @search
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
# Forest::Boat#harbor and Forest::Captain#license share their name with a relation of the model.
|
|
4
|
+
# With the relation's target left out of the apimap, SchemaAdapter drops the relation and only the
|
|
5
|
+
# smart field remains: the request reads it off the root row, not off the unexposed target.
|
|
6
|
+
describe 'A computed smart field named like a relation to an unexposed collection', type: :request do
|
|
7
|
+
token = JWT.encode({ id: 38, email: 'michael.kelso@that70.show', first_name: 'Michael',
|
|
8
|
+
last_name: 'Kelso', team: 'Operations', rendering_id: 16,
|
|
9
|
+
exp: Time.now.to_i + 2.weeks.to_i, permission_level: 'admin' },
|
|
10
|
+
ForestLiana.auth_secret, 'HS256')
|
|
11
|
+
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
|
|
12
|
+
'Authorization' => "Bearer #{token}" }
|
|
13
|
+
page = { 'number' => '1', 'size' => '15' }
|
|
14
|
+
|
|
15
|
+
let!(:harbor) { Harbor.create!(name: 'Saint-Malo') }
|
|
16
|
+
let!(:captain) { Captain.create!(name: 'Haddock', license_number: 'FR-4421') }
|
|
17
|
+
let!(:license) { License.create!(number: 'FR-4421', expires_on: Date.new(2030, 1, 1)) }
|
|
18
|
+
let!(:boat) { Boat.create!(name: 'Karaboudjan', harbor: harbor, captain: captain) }
|
|
19
|
+
|
|
20
|
+
let(:unexposed) { [] }
|
|
21
|
+
let(:relations_kept) { false }
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
|
|
25
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
26
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
27
|
+
allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
|
|
28
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes)
|
|
29
|
+
.and_return('scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' })
|
|
30
|
+
allow(ForestLiana).to receive(:apimap).and_wrap_original do |original|
|
|
31
|
+
original.call.reject { |collection| unexposed.include?(collection.name.to_s) }
|
|
32
|
+
end
|
|
33
|
+
# The apimap was built at boot with every target exposed: drop the relations to the unexposed
|
|
34
|
+
# ones, as SchemaAdapter does for a model left out of it.
|
|
35
|
+
ForestLiana.apimap.each do |collection|
|
|
36
|
+
allow(collection).to receive(:fields).and_wrap_original do |original|
|
|
37
|
+
next original.call if relations_kept
|
|
38
|
+
|
|
39
|
+
original.call.reject { |field| unexposed.include?(field[:reference].to_s.split('.').first) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def body
|
|
45
|
+
JSON.parse(response.body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
shared_examples 'serving the smart field off the root' do
|
|
49
|
+
context 'when the belongs_to target is unexposed' do
|
|
50
|
+
let(:unexposed) { ['Harbor'] }
|
|
51
|
+
|
|
52
|
+
it 'serves the list' do
|
|
53
|
+
get '/forest/Boat', params: { fields: { 'Boat' => 'id,name,harbor' }, page: page, timezone: 'Europe/Paris' },
|
|
54
|
+
headers: headers
|
|
55
|
+
|
|
56
|
+
expect(response.status).to eq(200)
|
|
57
|
+
expect(body['data'].map { |row| row['attributes']['harbor'] }).to eq(['Saint-Malo'])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'serves the get-one' do
|
|
61
|
+
get "/forest/Boat/#{boat.id}", params: { fields: { 'Boat' => 'id,name,harbor' }, timezone: 'Europe/Paris' },
|
|
62
|
+
headers: headers
|
|
63
|
+
|
|
64
|
+
expect(response.status).to eq(200)
|
|
65
|
+
expect(body['data']['attributes']['harbor']).to eq('Saint-Malo')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context 'when the has_one target is unexposed' do
|
|
70
|
+
let(:unexposed) { ['License'] }
|
|
71
|
+
|
|
72
|
+
it 'serves the list' do
|
|
73
|
+
get '/forest/Captain', params: { fields: { 'Captain' => 'id,name,license' }, page: page, timezone: 'Europe/Paris' },
|
|
74
|
+
headers: headers
|
|
75
|
+
|
|
76
|
+
expect(response.status).to eq(200)
|
|
77
|
+
expect(body['data'].map { |row| row['attributes']['license'] }).to eq(['FR-4421'])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'serves the get-one' do
|
|
81
|
+
get "/forest/Captain/#{captain.id}", params: { fields: { 'Captain' => 'id,name,license' }, timezone: 'Europe/Paris' },
|
|
82
|
+
headers: headers
|
|
83
|
+
|
|
84
|
+
expect(response.status).to eq(200)
|
|
85
|
+
expect(body['data']['attributes']['license']).to eq('FR-4421')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A target missing from the apimap without being excluded (a table absent at boot, a secondary
|
|
91
|
+
# database unreachable) leaves its relation in the schema next to the smart field.
|
|
92
|
+
context 'when the relation to the unexposed target stays in the schema' do
|
|
93
|
+
let(:relations_kept) { true }
|
|
94
|
+
|
|
95
|
+
before { Rails.cache.write('forest.has_permission', false) }
|
|
96
|
+
|
|
97
|
+
include_examples 'serving the smart field off the root'
|
|
98
|
+
|
|
99
|
+
it "still refuses the unexposed target's own fields" do
|
|
100
|
+
unexposed << 'Harbor'
|
|
101
|
+
|
|
102
|
+
get '/forest/Boat', params: { fields: { 'Boat' => 'id,name,harbor', 'harbor' => 'name' }, page: page,
|
|
103
|
+
timezone: 'Europe/Paris' },
|
|
104
|
+
headers: headers
|
|
105
|
+
|
|
106
|
+
expect(response.status).to eq(403)
|
|
107
|
+
expect(body['errors'].first['data']['unexposed_fields']).to eq(['name'])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
context 'with skip_relation_read_permissions, as Qonto runs it' do
|
|
112
|
+
before do
|
|
113
|
+
allow(ForestLiana).to receive(:skip_relation_read_permissions?) { true }
|
|
114
|
+
Rails.cache.write('forest.has_permission', true)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
include_examples 'serving the smart field off the root'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
context 'without a permission system' do
|
|
121
|
+
before { Rails.cache.write('forest.has_permission', false) }
|
|
122
|
+
|
|
123
|
+
include_examples 'serving the smart field off the root'
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
# devise_invitable declares `belongs_to :invited_by, polymorphic: true` with no `as: :invited_by`
|
|
4
|
+
# inverse anywhere, so no target can be resolved for it (#824).
|
|
5
|
+
describe 'A polymorphic relation with no declared target', type: :request do
|
|
6
|
+
token = JWT.encode({ id: 1, email: 'michael.kelso@that70.show', first_name: 'Michael',
|
|
7
|
+
last_name: 'Kelso', team: 'Operations', rendering_id: 16,
|
|
8
|
+
exp: Time.now.to_i + 2.weeks.to_i, permission_level: 'admin' },
|
|
9
|
+
ForestLiana.auth_secret, 'HS256')
|
|
10
|
+
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
|
|
11
|
+
'Authorization' => "Bearer #{token}" }
|
|
12
|
+
|
|
13
|
+
let!(:user) { User.create!(name: 'Michel') }
|
|
14
|
+
let!(:address) { Address.create!(line1: '1 Palm Street', city: 'Papeete', zipcode: '98713', addressable: user) }
|
|
15
|
+
|
|
16
|
+
before do
|
|
17
|
+
Rails.cache.clear
|
|
18
|
+
Rails.cache.write('forest.users', { '1' => { 'id' => 1, 'roleId' => 1, 'rendering_id' => '1' } })
|
|
19
|
+
Rails.cache.write('forest.has_permission', true)
|
|
20
|
+
enabled = { 'roles' => [1] }
|
|
21
|
+
allow_any_instance_of(ForestLiana::Ability::Fetch).to receive(:get_permissions)
|
|
22
|
+
.with('/liana/v4/permissions/environment').and_return(
|
|
23
|
+
'collections' => %w[Address User].to_h do |name|
|
|
24
|
+
[name, { 'collection' => %w[browseEnabled readEnabled editEnabled addEnabled deleteEnabled exportEnabled]
|
|
25
|
+
.to_h { |key| [key, enabled] }, 'actions' => {} }]
|
|
26
|
+
end
|
|
27
|
+
)
|
|
28
|
+
allow(ForestLiana::SchemaUtils).to receive(:polymorphic_models).and_call_original
|
|
29
|
+
allow(ForestLiana::SchemaUtils).to receive(:polymorphic_models)
|
|
30
|
+
.with(Address.reflect_on_association(:addressable)).and_return([])
|
|
31
|
+
|
|
32
|
+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
|
|
33
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
34
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
35
|
+
allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
|
|
36
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes)
|
|
37
|
+
.and_return('scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
after do
|
|
41
|
+
Address.destroy_all
|
|
42
|
+
User.destroy_all
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'serves the get-one without the relation instead of refusing it' do
|
|
46
|
+
get "/forest/Address/#{address.id}",
|
|
47
|
+
params: { fields: { 'Address' => 'id,line1,addressable', 'addressable' => 'id' }, timezone: 'Europe/Paris' },
|
|
48
|
+
headers: headers
|
|
49
|
+
|
|
50
|
+
expect(response.status).to eq(200)
|
|
51
|
+
body = JSON.parse(response.body)
|
|
52
|
+
expect(body['data']['attributes']['line1']).to eq('1 Palm Street')
|
|
53
|
+
expect(body['data'].fetch('relationships', {})).not_to have_key('addressable')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'still refuses a sort on the relation' do
|
|
57
|
+
get '/forest/Address',
|
|
58
|
+
params: { fields: { 'Address' => 'id,line1' }, sort: 'addressable.id', page: { 'number' => '1', 'size' => '10' },
|
|
59
|
+
timezone: 'Europe/Paris' },
|
|
60
|
+
headers: headers
|
|
61
|
+
|
|
62
|
+
expect(response.status).to eq(403)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -284,6 +284,64 @@ module ForestLiana
|
|
|
284
284
|
end
|
|
285
285
|
end
|
|
286
286
|
|
|
287
|
+
# PRD-1429: with `island` left out of the apimap, a computed smart field of the same name is
|
|
288
|
+
# what a bare path means. A traversal still means the relation, whose table the query joins.
|
|
289
|
+
describe 'a computed smart field sharing its name with a relation absent from the apimap' do
|
|
290
|
+
before do
|
|
291
|
+
forest_collection = double('forest_collection')
|
|
292
|
+
allow(forest_collection).to receive(:name).and_return('Tree')
|
|
293
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
294
|
+
allow(forest_collection).to receive(:fields).and_return([{ field: :island, is_virtual: true }])
|
|
295
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
296
|
+
write_permissions('Tree' => true)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it 'serves a sort on the smart field off the root' do
|
|
300
|
+
expect { dummy_class.assert_can_read_query_fields(user, Tree, sort_paths: ['island']) }
|
|
301
|
+
.not_to raise_error
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it 'still refuses a filter traversing the relation' do
|
|
305
|
+
expect { dummy_class.assert_can_read_query_fields(user, Tree, filter_paths: ['island:name']) }
|
|
306
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
context 'when the relation stays in the schema next to the smart field' do
|
|
310
|
+
before do
|
|
311
|
+
allow(ForestLiana.apimap.first).to receive(:fields).and_return(
|
|
312
|
+
[{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false },
|
|
313
|
+
{ field: :island, is_virtual: true }]
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it 'serves a sort on the smart field off the root all the same' do
|
|
318
|
+
expect { dummy_class.assert_can_read_query_fields(user, Tree, sort_paths: ['island']) }
|
|
319
|
+
.not_to raise_error
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# A polymorphic relation's reference names the association (`addressable.id`), never one of
|
|
325
|
+
# its targets, so it stays next to the smart field and keeps the name.
|
|
326
|
+
describe 'a computed smart field sharing its name with a polymorphic relation to unexposed targets' do
|
|
327
|
+
before do
|
|
328
|
+
forest_collection = double('forest_collection')
|
|
329
|
+
allow(forest_collection).to receive(:name).and_return('Address')
|
|
330
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
331
|
+
allow(forest_collection).to receive(:fields).and_return(
|
|
332
|
+
[{ field: 'addressable', reference: 'addressable.id', relationship: 'BelongsTo', is_virtual: false },
|
|
333
|
+
{ field: :addressable, is_virtual: true }]
|
|
334
|
+
)
|
|
335
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
336
|
+
write_permissions('Address' => true)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it 'keeps refusing a sort on it' do
|
|
340
|
+
expect { dummy_class.assert_can_read_query_fields(user, Address, sort_paths: ['addressable']) }
|
|
341
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
287
345
|
describe 'polymorphic' do
|
|
288
346
|
it 'serves a filter on a relation whose every target is readable' do
|
|
289
347
|
write_permissions('Address' => true, 'User' => true, 'Island' => true)
|
|
@@ -299,13 +299,56 @@ module ForestLiana
|
|
|
299
299
|
end
|
|
300
300
|
|
|
301
301
|
describe 'a collection absent from the apimap' do
|
|
302
|
+
let(:fields) { [] }
|
|
303
|
+
|
|
302
304
|
before do
|
|
303
305
|
forest_collection = double('forest_collection')
|
|
304
306
|
allow(forest_collection).to receive(:name).and_return('Tree')
|
|
305
307
|
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
308
|
+
allow(forest_collection).to receive(:fields).and_return(fields)
|
|
306
309
|
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
307
310
|
end
|
|
308
311
|
|
|
312
|
+
# SchemaAdapter drops `island` from the schema here, leaving a computed smart field of the
|
|
313
|
+
# same name as the only field the caller can mean (PRD-1429).
|
|
314
|
+
context 'when a computed smart field shares its name with the relation' do
|
|
315
|
+
let(:fields) { [{ field: :island, is_virtual: true }] }
|
|
316
|
+
|
|
317
|
+
it 'serves it off the root instead of refusing the request' do
|
|
318
|
+
write_permissions('Tree' => true)
|
|
319
|
+
|
|
320
|
+
expect(dummy_class.redact_fields(user, Tree, { 'Tree' => 'name,island' }, named_collections: ['Tree']))
|
|
321
|
+
.to eq('Tree' => 'name,island')
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# SchemaAdapter keeps the relation when Island is missing from the apimap without being
|
|
326
|
+
# excluded (a table absent at boot, a secondary database unreachable).
|
|
327
|
+
context 'when the relation stays in the schema next to the smart field' do
|
|
328
|
+
let(:fields) do
|
|
329
|
+
[{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false },
|
|
330
|
+
{ field: :island, is_virtual: true }]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it 'serves the smart field off the root all the same' do
|
|
334
|
+
write_permissions('Tree' => true)
|
|
335
|
+
|
|
336
|
+
expect(dummy_class.redact_fields(user, Tree, { 'Tree' => 'name,island' }, named_collections: ['Tree']))
|
|
337
|
+
.to eq('Tree' => 'name,island')
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
context 'when only the relation stays in the schema' do
|
|
342
|
+
let(:fields) { [{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false }] }
|
|
343
|
+
|
|
344
|
+
it 'keeps refusing it' do
|
|
345
|
+
write_permissions('Tree' => true)
|
|
346
|
+
|
|
347
|
+
expect { dummy_class.redact_fields(user, Tree, { 'Tree' => 'island' }, named_collections: ['Tree']) }
|
|
348
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
309
352
|
it 'names it as unexposed rather than as merely denied, since no role can be granted read on it' do
|
|
310
353
|
write_permissions('Tree' => true, 'Island' => false)
|
|
311
354
|
|
|
@@ -328,6 +371,58 @@ module ForestLiana
|
|
|
328
371
|
end
|
|
329
372
|
end
|
|
330
373
|
|
|
374
|
+
# The exposed relation still reaches the response alongside the smart field, so its read
|
|
375
|
+
# check stands: only an unexposed target hands the name over to the smart field.
|
|
376
|
+
describe 'a computed smart field sharing its name with an exposed relation' do
|
|
377
|
+
before do
|
|
378
|
+
forest_collection = double('forest_collection')
|
|
379
|
+
allow(forest_collection).to receive(:name).and_return('Tree')
|
|
380
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
381
|
+
allow(forest_collection).to receive(:fields).and_return([{ field: :island, is_virtual: true }])
|
|
382
|
+
island_collection = double('island_collection')
|
|
383
|
+
allow(island_collection).to receive(:name).and_return('Island')
|
|
384
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection, island_collection])
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
it 'keeps checking the relation' do
|
|
388
|
+
write_permissions('Tree' => true, 'Island' => false)
|
|
389
|
+
|
|
390
|
+
expect { dummy_class.redact_fields(user, Tree, { 'Tree' => 'island' }, named_collections: ['Tree']) }
|
|
391
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError) do |error|
|
|
392
|
+
expect(error.message).to eq("You are not allowed to read 'island' from the 'Island' collection.")
|
|
393
|
+
expect(error.data[:unexposed_fields]).to be_nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# SchemaAdapter keeps a polymorphic relation in the schema even when none of its targets is
|
|
399
|
+
# exposed, so a computed smart field of the same name does not take the name over.
|
|
400
|
+
describe 'a computed smart field sharing its name with a polymorphic relation to unexposed targets' do
|
|
401
|
+
before do
|
|
402
|
+
forest_collection = double('forest_collection')
|
|
403
|
+
allow(forest_collection).to receive(:name).and_return('Address')
|
|
404
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
405
|
+
allow(forest_collection).to receive(:fields).and_return(
|
|
406
|
+
[{ field: 'addressable', reference: 'addressable.id', relationship: 'BelongsTo', is_virtual: false },
|
|
407
|
+
{ field: :addressable, is_virtual: true }]
|
|
408
|
+
)
|
|
409
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
410
|
+
write_permissions('Address' => true)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
it 'keeps refusing the root field' do
|
|
414
|
+
expect { dummy_class.redact_fields(user, Address, { 'Address' => 'line1,addressable' }, named_collections: ['Address']) }
|
|
415
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it "keeps refusing the targets' own fields" do
|
|
419
|
+
expect do
|
|
420
|
+
dummy_class.redact_fields(user, Address, { 'Address' => 'line1', 'addressable' => 'name' },
|
|
421
|
+
named_collections: %w[Address addressable])
|
|
422
|
+
end.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
331
426
|
# Every real collection stays in the apimap here (only Island is rejected), unlike the
|
|
332
427
|
# describe block above — so 'owner' resolves to an exposed-but-denied User, not a second
|
|
333
428
|
# unexposed collection, and the two entries' differently-shaped clauses still join as one
|
|
@@ -415,16 +510,17 @@ module ForestLiana
|
|
|
415
510
|
%w[addresses addresses= address_ids address_ids=].each { |m| Island.undef_method(m) rescue nil }
|
|
416
511
|
end
|
|
417
512
|
|
|
418
|
-
it '
|
|
513
|
+
it 'drops a relation with no declared target even when the caller named it' do
|
|
419
514
|
write_permissions('Tree' => true)
|
|
420
515
|
Tree.class_eval { belongs_to :subject, polymorphic: true, optional: true }
|
|
421
516
|
|
|
422
|
-
expect
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
517
|
+
expect(dummy_class.redact_fields(user, Tree, { 'subject' => 'id' }, named_collections: ['subject']))
|
|
518
|
+
.to eq({})
|
|
519
|
+
expect(
|
|
520
|
+
dummy_class.redact_fields(
|
|
521
|
+
user, Tree, { 'Tree' => 'id,subject', 'subject' => 'id' }, named_collections: %w[Tree subject]
|
|
522
|
+
)
|
|
523
|
+
).to eq('Tree' => 'id')
|
|
428
524
|
ensure
|
|
429
525
|
Tree._reflections.delete('subject')
|
|
430
526
|
Tree._reflections.delete(:subject)
|
|
@@ -540,6 +540,89 @@ module ForestLiana
|
|
|
540
540
|
end
|
|
541
541
|
end
|
|
542
542
|
|
|
543
|
+
describe '#initialize' do
|
|
544
|
+
it 'starts with an empty search footprint' do
|
|
545
|
+
expect(builder.search_field_paths).to eq([])
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
describe 'when a host app overrides search_param without calling super' do
|
|
550
|
+
let(:builder_class) do
|
|
551
|
+
Class.new(described_class) do
|
|
552
|
+
def search_param
|
|
553
|
+
return @resource unless @search
|
|
554
|
+
|
|
555
|
+
@resource.where('LOWER(trees.name) LIKE ?', "%#{@search.downcase}%")
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
let(:builder) { builder_class.new(params, [], collection, user) }
|
|
560
|
+
|
|
561
|
+
before do
|
|
562
|
+
Tree.create!(name: 'Oak')
|
|
563
|
+
Tree.create!(name: 'Pine')
|
|
564
|
+
end
|
|
565
|
+
after { Tree.destroy_all }
|
|
566
|
+
|
|
567
|
+
context 'without a search' do
|
|
568
|
+
let(:params) { { searchExtended: '0' } }
|
|
569
|
+
|
|
570
|
+
it 'serves every record' do
|
|
571
|
+
expect(builder.perform(Tree.all).count).to eq(2)
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
context 'with a search the override matches' do
|
|
576
|
+
let(:params) { { search: 'oak', searchExtended: '0' } }
|
|
577
|
+
|
|
578
|
+
it "serves the override's result instead of emptying it" do
|
|
579
|
+
expect(builder.perform(Tree.all).map(&:name)).to eq(['Oak'])
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
context 'with a smart-search lambda and a malformed-UUID-shaped search' do
|
|
584
|
+
let(:builder_class) do
|
|
585
|
+
Class.new(described_class) do
|
|
586
|
+
def search_param
|
|
587
|
+
@resource
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
before do
|
|
593
|
+
allow(ForestLiana).to receive(:schema_for_resource).and_return(
|
|
594
|
+
ForestLiana::Model::Collection.new(
|
|
595
|
+
name: 'Tree', fields: [{ field: :custom, type: 'String', search: ->(query, _search) { query.where(name: 'Oak') } }]
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
let(:params) { { search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '0' } }
|
|
601
|
+
|
|
602
|
+
it "serves the lambda's result without logging it as discarded" do
|
|
603
|
+
expect(FOREST_LOGGER).not_to receive(:info).with(/was discarded/)
|
|
604
|
+
|
|
605
|
+
expect(builder.perform(Tree.all).map(&:name)).to eq(['Oak'])
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
context 'when the override calls super' do
|
|
610
|
+
let(:builder_class) do
|
|
611
|
+
Class.new(described_class) do
|
|
612
|
+
def search_param
|
|
613
|
+
super
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
let(:collection) { ForestLiana::Model::Collection.new(name: 'Tree', fields: [], search_fields: ['nonexistent']) }
|
|
618
|
+
let(:params) { { search: 'nothing-matches-this', searchExtended: '0' } }
|
|
619
|
+
|
|
620
|
+
it 'still answers no records when nothing could match' do
|
|
621
|
+
expect(builder.perform(Tree.all).count).to eq(0)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
543
626
|
describe 'a blank or whitespace-only search' do
|
|
544
627
|
let(:params) { ActiveSupport::HashWithIndifferentAccess.new(search: ' ', searchExtended: '1') }
|
|
545
628
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_liana
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 9.22.
|
|
4
|
+
version: 9.22.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sandro Munda
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -454,6 +454,8 @@ files:
|
|
|
454
454
|
- spec/dummy/db/schema.rb
|
|
455
455
|
- spec/dummy/db/user_schema.rb
|
|
456
456
|
- spec/dummy/lib/forest_liana/collections/address.rb
|
|
457
|
+
- spec/dummy/lib/forest_liana/collections/boat.rb
|
|
458
|
+
- spec/dummy/lib/forest_liana/collections/captain.rb
|
|
457
459
|
- spec/dummy/lib/forest_liana/collections/island.rb
|
|
458
460
|
- spec/dummy/lib/forest_liana/collections/location.rb
|
|
459
461
|
- spec/dummy/lib/forest_liana/collections/owner.rb
|
|
@@ -484,9 +486,11 @@ files:
|
|
|
484
486
|
- spec/requests/query_footprint_spec.rb
|
|
485
487
|
- spec/requests/related_list_nested_through_spec.rb
|
|
486
488
|
- spec/requests/resources_spec.rb
|
|
489
|
+
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
487
490
|
- spec/requests/stats_spec.rb
|
|
488
491
|
- spec/requests/sti_record_conversion_spec.rb
|
|
489
492
|
- spec/requests/test.ru
|
|
493
|
+
- spec/requests/unresolved_polymorphic_relation_spec.rb
|
|
490
494
|
- spec/requests/workflow_executions_spec.rb
|
|
491
495
|
- spec/routing/routes_spec.rb
|
|
492
496
|
- spec/serializers/forest_liana/missing_attribute_valve_spec.rb
|
|
@@ -808,6 +812,8 @@ test_files:
|
|
|
808
812
|
- spec/dummy/db/schema.rb
|
|
809
813
|
- spec/dummy/db/user_schema.rb
|
|
810
814
|
- spec/dummy/lib/forest_liana/collections/address.rb
|
|
815
|
+
- spec/dummy/lib/forest_liana/collections/boat.rb
|
|
816
|
+
- spec/dummy/lib/forest_liana/collections/captain.rb
|
|
811
817
|
- spec/dummy/lib/forest_liana/collections/island.rb
|
|
812
818
|
- spec/dummy/lib/forest_liana/collections/location.rb
|
|
813
819
|
- spec/dummy/lib/forest_liana/collections/owner.rb
|
|
@@ -838,9 +844,11 @@ test_files:
|
|
|
838
844
|
- spec/requests/query_footprint_spec.rb
|
|
839
845
|
- spec/requests/related_list_nested_through_spec.rb
|
|
840
846
|
- spec/requests/resources_spec.rb
|
|
847
|
+
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
841
848
|
- spec/requests/stats_spec.rb
|
|
842
849
|
- spec/requests/sti_record_conversion_spec.rb
|
|
843
850
|
- spec/requests/test.ru
|
|
851
|
+
- spec/requests/unresolved_polymorphic_relation_spec.rb
|
|
844
852
|
- spec/requests/workflow_executions_spec.rb
|
|
845
853
|
- spec/routing/routes_spec.rb
|
|
846
854
|
- spec/serializers/forest_liana/missing_attribute_valve_spec.rb
|