forest_admin_agent 1.39.1 → 1.39.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 428e77bbb38c12b3b742940d6389b10953d719dbbb01ee121130dd01f9eb8868
4
- data.tar.gz: 1b87f61a2aceb41b7ecd1e1c65339374a3f50c9faccaaf48331cebbff73a8760
3
+ metadata.gz: 5002fa757fc6f7be665d7b966a47e8d7f69c600b0061092fc4ae0d9cd25ff2c7
4
+ data.tar.gz: efd52bb2fcc984e3cf589b3933cc5876c8f93071daad61084435fa71f50f276b
5
5
  SHA512:
6
- metadata.gz: 0d1408d54a4650b3850fd62647704eaca320f376603639328d3e2356b0064adc1a2cbd05920040eafd0314fce89cba22fb09456b8244a9d6e238c21bc7c54b50
7
- data.tar.gz: a30686d3e81a9febf50189dc66249922c288ab33c9868f51c6d91ff40c2ace58c512bd185e2dd2230c35bbf78e2823485db9ea5685f745bf4183c17a44c9bf64
6
+ metadata.gz: f02eff4908523d3f0d0e38bd1b673ecbdfc7c6aee8613c6c55fb0ef526a23eb22fe4a033f7a61d77f6a96176f7df7cde4d69a50460a7adf06a934bbf8a854d19
7
+ data.tar.gz: c9c02167321c811f1318edc433ef8f93b12f5e2bd74115c13983dcbcb8f9a3918c56ed1b34ba066a947c8115934e167203572d6558313cae3d3668ea171e9103
@@ -12,6 +12,26 @@ module ForestAdminAgent
12
12
  context
13
13
  end
14
14
 
15
+ # Never refused: a write must not 403 because the row it just wrote carries a relation the
16
+ # caller cannot read.
17
+ def redacted_full_projection(context)
18
+ all = ForestAdminDatasourceToolkit::Components::Query::ProjectionFactory.all(context.collection)
19
+
20
+ context.permissions.redact_projection(context.collection, all, named_by_caller: false)
21
+ end
22
+
23
+ # +with_pks+ runs after the redaction on purpose. It only re-adds keys for relations the
24
+ # redaction kept a path through, and the serializer needs those keys to emit the readable
25
+ # column behind them — dropping them would take the permitted path down with them. A relation
26
+ # the redaction emptied contributes no path, so nothing is re-added for it.
27
+ def redacted_projection_with_pks(context, collection, args)
28
+ requested = Utils::QueryStringParser.parse_requested_projection(collection, args)
29
+
30
+ context.permissions.redact_projection(
31
+ collection, requested[:projection], named_by_caller: requested[:named_by_caller]
32
+ ).with_pks(collection)
33
+ end
34
+
15
35
  def format_attributes(args, collection)
16
36
  record = args[:params][:data][:attributes] || {}
17
37
 
@@ -28,15 +28,14 @@ module ForestAdminAgent
28
28
  def handle_request(args = {})
29
29
  context = build(args)
30
30
  context.permissions.can_chart?(args[:params])
31
+ condition_tree = ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(
32
+ context.collection, args
33
+ )
34
+ context.permissions.assert_can_read_query_fields(context.collection, condition_tree: condition_tree)
31
35
  type = validate_and_get_type(args[:params][:type])
32
36
  filter = Filter.new(
33
37
  condition_tree: ConditionTreeFactory.intersect(
34
- [
35
- context.permissions.get_scope(context.collection),
36
- ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(
37
- context.collection, args
38
- )
39
- ]
38
+ [context.permissions.get_scope(context.collection), condition_tree]
40
39
  )
41
40
  )
42
41
 
@@ -47,6 +46,15 @@ module ForestAdminAgent
47
46
 
48
47
  private
49
48
 
49
+ # An empty `aggregateFieldName` is a count, and `can_chart?` cannot tell it from an absent
50
+ # one — `sanitize_chart_parameters` drops both before hashing. Normalising here keeps the
51
+ # aggregation and the guards that read it from disagreeing on what a count is.
52
+ def aggregate_field_name(args)
53
+ field = args[:params][:aggregateFieldName]
54
+
55
+ field.nil? || field.to_s.empty? ? nil : field
56
+ end
57
+
50
58
  def validate_and_get_type(type)
51
59
  chart_types = %w[Value Objective Pie Line Leaderboard]
52
60
  unless chart_types.include?(type)
@@ -89,9 +97,13 @@ module ForestAdminAgent
89
97
 
90
98
  def make_pie(context, filter, args)
91
99
  group_field = args[:params][:groupByFieldName]
100
+ assert_can_read_aggregated_fields(
101
+ context, context.collection,
102
+ [['group a chart by', group_field], ['aggregate a chart on', aggregate_field_name(args)]]
103
+ )
92
104
  aggregation = Aggregation.new(
93
105
  operation: args[:params][:aggregator],
94
- field: args[:params][:aggregateFieldName],
106
+ field: aggregate_field_name(args),
95
107
  groups: group_field ? [{ field: group_field }] : []
96
108
  )
97
109
 
@@ -102,6 +114,11 @@ module ForestAdminAgent
102
114
 
103
115
  def make_line(context, filter, args)
104
116
  group_by_field_name = args[:params][:groupByFieldName]
117
+ assert_can_read_aggregated_fields(
118
+ context, context.collection,
119
+ [['group a chart by', group_by_field_name],
120
+ ['aggregate a chart on', aggregate_field_name(args)]]
121
+ )
105
122
  time_range = args[:params][:timeRange]
106
123
  filter_only_with_values = filter.override(
107
124
  condition_tree: ConditionTree::ConditionTreeFactory.intersect(
@@ -116,7 +133,7 @@ module ForestAdminAgent
116
133
  filter_only_with_values,
117
134
  Aggregation.new(
118
135
  operation: args[:params][:aggregator],
119
- field: args[:params][:aggregateFieldName],
136
+ field: aggregate_field_name(args),
120
137
  groups: [{ field: group_by_field_name, operation: time_range }]
121
138
  )
122
139
  )
@@ -151,7 +168,7 @@ module ForestAdminAgent
151
168
  leaderboard_filter = filter.nest(inverse)
152
169
  aggregation = Aggregation.new(
153
170
  operation: args[:params][:aggregator],
154
- field: args[:params][:aggregateFieldName],
171
+ field: aggregate_field_name(args),
155
172
  groups: [{ field: "#{inverse}:#{args[:params][:labelFieldName]}" }]
156
173
  )
157
174
  end
@@ -171,13 +188,25 @@ module ForestAdminAgent
171
188
  leaderboard_filter = filter.nest(origin)
172
189
  aggregation = Aggregation.new(
173
190
  operation: args[:params][:aggregator],
174
- field: args[:params][:aggregateFieldName] ? "#{target}:#{args[:params][:aggregateFieldName]}" : nil,
191
+ field: aggregate_field_name(args) ? "#{target}:#{aggregate_field_name(args)}" : nil,
175
192
  groups: [{ field: "#{origin}:#{args[:params][:labelFieldName]}" }]
176
193
  )
177
194
  end
178
195
  end
179
196
 
180
197
  if collection && leaderboard_filter && aggregation
198
+ assert_can_read_aggregated_fields(
199
+ context, context.datasource.get_collection(collection),
200
+ [['group a leaderboard by', aggregation.groups[0][:field]],
201
+ ['aggregate a leaderboard on', aggregation.field]]
202
+ )
203
+
204
+ # A count exposes the cardinality of the relation, which `/relationships/<name>/count`
205
+ # puts behind `browse`. No path names it, so nothing above sees it.
206
+ if aggregation.field.nil?
207
+ context.permissions.can?(:browse, context.datasource.get_collection(field.foreign_collection))
208
+ end
209
+
181
210
  rows = context.datasource.get_collection(collection).aggregate(
182
211
  context.caller,
183
212
  leaderboard_filter,
@@ -200,12 +229,33 @@ module ForestAdminAgent
200
229
  end
201
230
 
202
231
  def compute_value(context, filter, args)
232
+ assert_can_read_aggregated_fields(
233
+ context, context.collection,
234
+ [['aggregate a chart on', aggregate_field_name(args)]]
235
+ )
203
236
  aggregation = Aggregation.new(operation: args[:params][:aggregator],
204
- field: args[:params][:aggregateFieldName])
237
+ field: aggregate_field_name(args))
205
238
  result = context.collection.aggregate(context.caller, filter, aggregation)
206
239
 
207
240
  result[0]['value'] || 0
208
241
  end
242
+
243
+ # The permission root stays the chart's own collection, which the leaderboard call site does
244
+ # not share with the collection its paths resolve against.
245
+ def assert_can_read_aggregated_fields(context, path_collection, fields)
246
+ usages = fields.reject { |_action, path| path.nil? || path.to_s.empty? }
247
+ .map do |action, path|
248
+ {
249
+ action: action,
250
+ path: path,
251
+ collections: ForestAdminDatasourceToolkit::Utils::FieldPath.leaf_collection_names(
252
+ path_collection, path
253
+ )
254
+ }
255
+ end
256
+
257
+ context.permissions.assert_can_read_usages(context.collection.name, usages)
258
+ end
209
259
  end
210
260
  end
211
261
  end
@@ -19,16 +19,26 @@ module ForestAdminAgent
19
19
  context.permissions.can?(:browse, context.collection)
20
20
 
21
21
  if context.collection.is_countable?
22
+ condition_tree = ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(
23
+ context.collection, args
24
+ )
25
+ search = QueryStringParser.parse_search(context.collection, args)
26
+ search_extended = QueryStringParser.parse_search_extended(args)
27
+ context.permissions.assert_can_read_query_fields(
28
+ context.collection,
29
+ condition_tree: condition_tree, search: search, search_extended: search_extended
30
+ )
31
+
22
32
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
23
33
  condition_tree: ConditionTreeFactory.intersect(
24
34
  [
25
35
  context.permissions.get_scope(context.collection),
26
36
  parse_query_segment(context.collection, args, context.permissions, context.caller),
27
- ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(context.collection, args)
37
+ condition_tree
28
38
  ]
29
39
  ),
30
- search: QueryStringParser.parse_search(context.collection, args),
31
- search_extended: QueryStringParser.parse_search_extended(args),
40
+ search: search,
41
+ search_extended: search_extended,
32
42
  segment: QueryStringParser.parse_segment(context.collection, args)
33
43
  )
34
44
  aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count')
@@ -22,25 +22,38 @@ module ForestAdminAgent
22
22
  context = build(args)
23
23
  context.permissions.can?(:browse, context.collection)
24
24
  context.permissions.can?(:export, context.collection)
25
+
26
+ condition_tree = QueryStringParser.parse_condition_tree(context.collection, args)
27
+ search = QueryStringParser.parse_search(context.collection, args)
28
+ search_extended = QueryStringParser.parse_search_extended(args)
29
+ sort = QueryStringParser.parse_sort(context.collection, args)
30
+ context.permissions.assert_can_read_query_fields(
31
+ context.collection,
32
+ condition_tree: condition_tree, sort: sort, search: search, search_extended: search_extended
33
+ )
34
+
25
35
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
26
36
  condition_tree: ConditionTreeFactory.intersect(
27
37
  [
28
38
  context.permissions.get_scope(context.collection),
29
39
  parse_query_segment(context.collection, args, context.permissions, context.caller),
30
- QueryStringParser.parse_condition_tree(
31
- context.collection, args
32
- )
40
+ condition_tree
33
41
  ]
34
42
  ),
35
- search: QueryStringParser.parse_search(context.collection, args),
36
- search_extended: QueryStringParser.parse_search_extended(args),
37
- sort: QueryStringParser.parse_sort(context.collection, args),
43
+ search: search,
44
+ search_extended: search_extended,
45
+ sort: sort,
38
46
  segment: QueryStringParser.parse_segment(context.collection, args)
39
47
  )
40
- projection = QueryStringParser.parse_projection_from_request(context.collection, args)
48
+ requested = QueryStringParser.parse_requested_projection(context.collection, args)
49
+ projection = context.permissions.redact_projection(
50
+ context.collection,
51
+ requested[:projection],
52
+ named_by_caller: requested[:named_by_caller]
53
+ )
41
54
  filename = args[:params][:filename] || args[:params]['collection_name']
42
55
  filename += '.csv' unless /\.csv$/i.match?(filename)
43
- header = args[:params][:header]
56
+ header = Utils::CsvGenerator.filter_header(args[:params][:header], requested[:projection], projection)
44
57
 
45
58
  # Generate timestamp for filename
46
59
  now = Time.now.strftime('%Y%m%d_%H%M%S')
@@ -18,22 +18,31 @@ module ForestAdminAgent
18
18
  context = build(args)
19
19
  context.permissions.can?(:browse, context.collection)
20
20
 
21
+ condition_tree = QueryStringParser.parse_condition_tree(context.collection, args)
22
+ search = QueryStringParser.parse_search(context.collection, args)
23
+ search_extended = QueryStringParser.parse_search_extended(args)
24
+ sort = QueryStringParser.parse_sort(context.collection, args)
25
+ context.permissions.assert_can_read_query_fields(
26
+ context.collection,
27
+ condition_tree: condition_tree, sort: sort, search: search, search_extended: search_extended
28
+ )
29
+
21
30
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
22
31
  condition_tree: ConditionTreeFactory.intersect(
23
32
  [
24
33
  context.permissions.get_scope(context.collection),
25
- QueryStringParser.parse_condition_tree(context.collection, args),
34
+ condition_tree,
26
35
  parse_query_segment(context.collection, args, context.permissions, context.caller)
27
36
  ]
28
37
  ),
29
38
  page: QueryStringParser.parse_pagination(args),
30
- search: QueryStringParser.parse_search(context.collection, args),
31
- search_extended: QueryStringParser.parse_search_extended(args),
32
- sort: QueryStringParser.parse_sort(context.collection, args),
39
+ search: search,
40
+ search_extended: search_extended,
41
+ sort: sort,
33
42
  segment: QueryStringParser.parse_segment(context.collection, args)
34
43
  )
35
44
 
36
- projection = QueryStringParser.parse_projection_with_pks(context.collection, args)
45
+ projection = redacted_projection_with_pks(context, context.collection, args)
37
46
  records = context.collection.list(context.caller, filter, projection)
38
47
 
39
48
  {
@@ -23,18 +23,26 @@ module ForestAdminAgent
23
23
  context = build(args)
24
24
  context.permissions.can?(:browse, context.child_collection)
25
25
  context.permissions.can?(:export, context.child_collection)
26
+ condition_tree = ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(
27
+ context.child_collection, args
28
+ )
29
+ context.permissions.assert_can_read_query_fields(
30
+ context.child_collection, condition_tree: condition_tree
31
+ )
26
32
 
27
33
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
28
34
  condition_tree: ConditionTreeFactory.intersect(
29
- [
30
- context.permissions.get_scope(context.child_collection),
31
- ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(context.child_collection, args)
32
- ]
35
+ [context.permissions.get_scope(context.child_collection), condition_tree]
33
36
  )
34
37
  )
35
- projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_from_request(
38
+ requested = ForestAdminAgent::Utils::QueryStringParser.parse_requested_projection(
36
39
  context.child_collection, args
37
40
  )
41
+ projection = context.permissions.redact_projection(
42
+ context.child_collection,
43
+ requested[:projection],
44
+ named_by_caller: requested[:named_by_caller]
45
+ )
38
46
 
39
47
  # Get the parent record primary keys
40
48
  primary_key_values = Utils::Id.unpack_id(context.collection, args[:params]['id'], with_key: true)
@@ -43,7 +51,9 @@ module ForestAdminAgent
43
51
  # Generate timestamp for filename
44
52
  now = Time.now.strftime('%Y%m%d_%H%M%S')
45
53
  collection_name = args.dig(:params, 'collection_name')
46
- header = args.dig(:params, 'header')
54
+ header = ForestAdminAgent::Utils::CsvGenerator.filter_header(
55
+ args.dig(:params, 'header'), requested[:projection], projection
56
+ )
47
57
  filename_with_timestamp = "#{collection_name}_#{relation_name}_export_#{now}.csv"
48
58
 
49
59
  # Create a callable to fetch related records
@@ -23,19 +23,22 @@ module ForestAdminAgent
23
23
  def handle_request(args = {})
24
24
  context = build(args)
25
25
  context.permissions.can?(:browse, context.child_collection)
26
+ condition_tree = ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(
27
+ context.child_collection, args
28
+ )
29
+ sort = ForestAdminAgent::Utils::QueryStringParser.parse_sort(context.child_collection, args)
30
+ context.permissions.assert_can_read_query_fields(
31
+ context.child_collection, condition_tree: condition_tree, sort: sort
32
+ )
26
33
 
27
34
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
28
35
  condition_tree: ConditionTreeFactory.intersect(
29
- [
30
- context.permissions.get_scope(context.child_collection),
31
- ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(context.child_collection, args)
32
- ]
36
+ [context.permissions.get_scope(context.child_collection), condition_tree]
33
37
  ),
34
38
  page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args),
35
- sort: ForestAdminAgent::Utils::QueryStringParser.parse_sort(context.child_collection, args)
39
+ sort: sort
36
40
  )
37
- projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_with_pks(context.child_collection,
38
- args)
41
+ projection = redacted_projection_with_pks(context, context.child_collection, args)
39
42
  primary_key_values = Utils::Id.unpack_id(context.collection, args[:params]['id'], with_key: true)
40
43
  records = Collection.list_relation(
41
44
  context.collection,
@@ -25,7 +25,7 @@ module ForestAdminAgent
25
25
  condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope])
26
26
  )
27
27
 
28
- projection = QueryStringParser.parse_projection_with_pks(context.collection, args)
28
+ projection = redacted_projection_with_pks(context, context.collection, args)
29
29
 
30
30
  records = context.collection.list(context.caller, filter, projection)
31
31
 
@@ -25,7 +25,8 @@ module ForestAdminAgent
25
25
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
26
26
  condition_tree: ConditionTree::ConditionTreeFactory.match_ids(context.collection, [id])
27
27
  )
28
- records = context.collection.list(context.caller, filter, ProjectionFactory.all(context.collection))
28
+ projection = redacted_full_projection(context)
29
+ records = context.collection.list(context.caller, filter, projection)
29
30
 
30
31
  {
31
32
  name: args[:params]['collection_name'],
@@ -26,7 +26,8 @@ module ForestAdminAgent
26
26
  drop_relationships!(args)
27
27
  data = format_attributes(args, context.collection)
28
28
  context.collection.update(context.caller, filter, data)
29
- records = context.collection.list(context.caller, filter, ProjectionFactory.all(context.collection))
29
+ projection = redacted_full_projection(context)
30
+ records = context.collection.list(context.caller, filter, projection)
30
31
 
31
32
  {
32
33
  name: args[:params]['collection_name'],
@@ -51,7 +51,8 @@ module ForestAdminAgent
51
51
  )
52
52
  context.collection.update(context.caller, filter, { field_name => updated_array })
53
53
 
54
- records = context.collection.list(context.caller, filter, ProjectionFactory.all(context.collection))
54
+ projection = redacted_full_projection(context)
55
+ records = context.collection.list(context.caller, filter, projection)
55
56
 
56
57
  {
57
58
  name: args[:params]['collection_name'],
@@ -93,7 +94,8 @@ module ForestAdminAgent
93
94
  filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
94
95
  condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope])
95
96
  )
96
- records = context.collection.list(context.caller, filter, ProjectionFactory.all(context.collection))
97
+ projection = redacted_full_projection(context)
98
+ records = context.collection.list(context.caller, filter, projection)
97
99
 
98
100
  raise Http::Exceptions::NotFoundError, 'Record not found' unless records&.any?
99
101
 
@@ -53,6 +53,83 @@ module ForestAdminAgent
53
53
  is_allowed
54
54
  end
55
55
 
56
+ # +root_collection_name+ is pinned to readable and never looked up: +browse+ already gates a
57
+ # listing, +read+ a get, and the signed hash a chart.
58
+ #
59
+ # Answered once per collection for the whole request: +redact_projection+ and
60
+ # +assert_can_read_query_fields+ both ask on a listing, and a chart asks three times.
61
+ def read_permissions(root_collection_name, collection_names)
62
+ to_check = collection_names.uniq.reject { |name| name == root_collection_name }
63
+ allowed = { root_collection_name => true }
64
+
65
+ return allowed if to_check.empty?
66
+
67
+ # An absent permission system is not a denial: `can?` allows everything there, and answering
68
+ # anything else would redact every relation on a deployment that granted nothing to check.
69
+ return allowed.merge(to_check.to_h { |name| [name, true] }) unless permission_system?
70
+
71
+ @read_permissions ||= {}
72
+ missing = to_check - @read_permissions.keys
73
+ @read_permissions.merge!(fetch_read_permissions(missing)) unless missing.empty?
74
+
75
+ allowed.merge(@read_permissions.slice(*to_check))
76
+ end
77
+
78
+ # An unnamed field is dropped rather than refused: the default expansion covers every column
79
+ # of every to-one relation, so refusing would turn an ordinary listing into a 403 for a caller
80
+ # that asked for nothing.
81
+ def redact_projection(collection, projection, named_by_caller:)
82
+ owners = projection.to_h do |path|
83
+ [path, ForestAdminDatasourceToolkit::Utils::FieldPath.leaf_collection_names(collection, path)]
84
+ end
85
+ allowed = read_permissions(collection.name, owners.values.flatten)
86
+ readable = ->(path) { readable_leaves?(owners[path], allowed) }
87
+
88
+ if named_by_caller
89
+ denied = projection.reject { |path| readable.call(path) }
90
+
91
+ unless denied.empty?
92
+ fields = denied.map { |path| "'#{path}' from #{leaf_label(owners[path])}" }
93
+ raise ForbiddenError, "You are not allowed to read #{fields.join(", ")}."
94
+ end
95
+ end
96
+
97
+ ForestAdminDatasourceToolkit::Components::Query::Projection.new(projection.select { |path| readable.call(path) })
98
+ end
99
+
100
+ # Refused rather than redacted: dropping a condition widens the result set and dropping a sort
101
+ # clause silently reorders it, while both leak the value they touch anyway — a `starts_with`
102
+ # filter answers one guess per request without returning a column of its own.
103
+ #
104
+ # The route passes the components it will actually apply, already parsed. A component it drops
105
+ # is simply one it does not pass — a count carries no sort, a chart neither sort nor search — so
106
+ # nothing has to be declared alongside the query and then kept in step with it. What is
107
+ # authorised here is what the filter carries, not a second parse of the same parameters.
108
+ def assert_can_read_query_fields(collection, condition_tree: nil, sort: nil, search: nil,
109
+ search_extended: false)
110
+ usages = []
111
+
112
+ # `projection` collects the leaf fields without touching the tree. The route applies this very
113
+ # instance next, so a traversal that rebuilt a branch — as `for_each_leaf` does — would leave
114
+ # the guard deciding what runs.
115
+ condition_tree&.projection&.each { |path| usages << usage('filter on', collection, path) }
116
+ sort&.each { |clause| usages << usage('sort on', collection, clause[:field]) }
117
+ collect_search_usages(collection, search, search_extended, usages)
118
+
119
+ assert_can_read_usages(collection.name, usages)
120
+ end
121
+
122
+ def assert_can_read_usages(root_collection_name, usages)
123
+ allowed = read_permissions(root_collection_name, usages.flat_map { |usage| usage[:collections] })
124
+ denied = usages.find { |usage| !readable_leaves?(usage[:collections], allowed) }
125
+
126
+ return unless denied
127
+
128
+ raise ForbiddenError,
129
+ "You cannot #{denied[:action]} '#{denied[:path]}': you are not allowed to read " \
130
+ "#{leaf_label(denied[:collections])}."
131
+ end
132
+
56
133
  def can_chart?(parameters)
57
134
  attributes = sanitize_chart_parameters(parameters.deep_symbolize_keys)
58
135
  hash_request = "#{attributes[:type]}:#{array_hash(attributes)}"
@@ -188,6 +265,103 @@ module ForestAdminAgent
188
265
 
189
266
  private
190
267
 
268
+ # An empty list of leaves resolves to no collection at all — a polymorphic relation declaring
269
+ # no `foreign_collections`. `[].all?` would allow it unconditionally, which is the one answer
270
+ # this guard must never give by default, so it counts as denied.
271
+ def readable_leaves?(names, allowed)
272
+ names.any? && names.all? { |name| allowed[name] }
273
+ end
274
+
275
+ def leaf_label(names)
276
+ names.empty? ? 'an unresolved polymorphic relation' : "the '#{names.join("' or '")}' collection"
277
+ end
278
+
279
+ def fetch_read_permissions(names)
280
+ user_data = get_user_data(caller.id)
281
+ collections_data = get_collections_permissions_data
282
+ results = names.to_h { |name| [name, read_allowed?(collections_data, name, user_data)] }
283
+
284
+ return results if results.values.all? || !refetch_denied_reads?(names, collections_data)
285
+
286
+ @read_permissions_refetched = true
287
+ refetched = get_collections_permissions_data(force_fetch: true)
288
+
289
+ names.to_h { |name| [name, read_allowed?(refetched, name, user_data)] }
290
+ end
291
+
292
+ # `can?` refetches the whole environment on every denial. Denial is the steady state of this
293
+ # check rather than the exception, so paying that per denial would cost a permission fetch, and
294
+ # a cache eviction every other in-flight request reads through, on each page load.
295
+ #
296
+ # Two things make a denial worth one fetch. Without `instant_cache_refresh` nothing else keeps
297
+ # the cache fresh — this is the gate node puts its own refetch behind — so a role granted `read`
298
+ # would otherwise stay redacted until the cache expires. With the channel up, `refresh-roles`
299
+ # evicts on a role change, and the only denial staleness still explains is a collection the
300
+ # payload has never heard of.
301
+ def refetch_denied_reads?(names, collections_data)
302
+ return false if @read_permissions_refetched
303
+
304
+ !instant_cache_refresh? || names.any? { |name| !collections_data.key?(name.to_sym) }
305
+ end
306
+
307
+ def instant_cache_refresh?
308
+ Facades::Container.config_from_cache[:instant_cache_refresh] == true
309
+ end
310
+
311
+ def read_allowed?(collections_data, collection_name, user_data)
312
+ return false unless user_data_valid?(user_data)
313
+
314
+ collection_key = collection_name.to_sym
315
+ return false unless collection_exists?(collections_data, collection_key, collection_name, user_data)
316
+
317
+ role_ids = get_role_ids_for_action(collections_data, collection_key, :read, collection_name, user_data)
318
+ return false unless role_ids
319
+
320
+ check_user_permission(role_ids, user_data, :read, collection_name)
321
+ end
322
+
323
+ def usage(action, collection, path)
324
+ {
325
+ action: action,
326
+ path: path,
327
+ collections: ForestAdminDatasourceToolkit::Utils::FieldPath.leaf_collection_names(collection, path)
328
+ }
329
+ end
330
+
331
+ def collect_search_usages(collection, search, search_extended, usages)
332
+ return if search.nil? || !collection.respond_to?(:searched_fields)
333
+
334
+ searched = collection.searched_fields(search, search_extended)
335
+
336
+ return if searched.nil?
337
+
338
+ published = collection.datasource.collections
339
+
340
+ searched.each do |field|
341
+ assert_search_target_exposed(field, published)
342
+ usages << { action: 'search on', path: field[:path], collections: field[:collections] }
343
+ end
344
+ end
345
+
346
+ # `searched_fields` answers below the publication layer — deliberately, so a field hidden by
347
+ # renaming above it is still checked — so it can name a collection `remove_collection` took out
348
+ # of the API. An extended search does reach through to it: the condition is built below
349
+ # publication, which has already passed the filter down and cannot strip it.
350
+ #
351
+ # That collection is absent from the permission payload too, so asking `read_allowed?` answers
352
+ # "denied" for every role, admins included, and no grant can lift it. So it is refused as what
353
+ # it is — a column the agent does not expose — which points at `disable_search` or publishing
354
+ # the collection again rather than at a permission to grant.
355
+ def assert_search_target_exposed(field, published)
356
+ unexposed = field[:collections].reject { |name| published.key?(name) }
357
+
358
+ return if unexposed.empty?
359
+
360
+ raise ForbiddenError,
361
+ "You cannot search on '#{field[:path]}': the '#{unexposed.join("' or '")}' collection " \
362
+ 'is not exposed by this agent.'
363
+ end
364
+
191
365
  def permission_allowed?(collections_data, collection, action, user_data)
192
366
  return false unless user_data_valid?(user_data)
193
367
 
@@ -3,6 +3,46 @@ require 'csv'
3
3
  module ForestAdminAgent
4
4
  module Utils
5
5
  class CsvGenerator
6
+ # Labels are positionally aligned with the requested projection, so dropping a field without
7
+ # dropping its label shifts every later value under the wrong heading.
8
+ # The header carries one label per exported column, so a label survives exactly when the
9
+ # projection path at its index did.
10
+ #
11
+ # A count that does not match the requested projection cannot be mapped onto columns — a to-one
12
+ # relation expanded into several paths, or a polymorphic type field appended — and there is then
13
+ # no header to hand back. Returning the original would leave `CsvGeneratorStream.parse_header`
14
+ # to check it against the *kept* count instead, so labels that happen to match the redacted
15
+ # column count would be accepted, printing each surviving column under the label of whatever
16
+ # preceded it. `nil` is what makes the stream fall back to the field paths.
17
+ #
18
+ # Handing it back untouched is only safe on the branch above, where nothing was redacted: there
19
+ # the two counts are the same number, so the stream's own check cannot reach a different answer.
20
+ def self.filter_header(header, requested, kept)
21
+ return header if header.nil? || kept.size == requested.size
22
+
23
+ labels = parse_header_labels(header)
24
+
25
+ return nil unless labels&.size == requested.size
26
+
27
+ labels.each_with_index
28
+ .select { |_label, index| kept.include?(requested[index]) }
29
+ .map(&:first)
30
+ end
31
+
32
+ # The front joins the labels with commas; a JSON array comes from older callers.
33
+ def self.parse_header_labels(header)
34
+ return header if header.is_a?(Array)
35
+ return nil unless header.is_a?(String)
36
+
37
+ parsed = begin
38
+ JSON.parse(header)
39
+ rescue JSON::ParserError
40
+ nil
41
+ end
42
+
43
+ parsed.is_a?(Array) ? parsed : header.split(',', -1)
44
+ end
45
+
6
46
  def self.generate(records, projection)
7
47
  data = {}
8
48
  projection.each do |schema_field|
@@ -16,8 +16,9 @@ module ForestAdminAgent
16
16
  def self.stream(header, filter, projection, list_records, limit_export_size = nil)
17
17
  Enumerator.new do |yielder|
18
18
  # Yield header row first (client receives immediately)
19
- header_array = parse_header(header, projection)
20
- yielder << "#{header_array.join(",")}\n"
19
+ # Escaped like any other row: the labels are the caller's, and one carrying a comma or a
20
+ # quote would otherwise emit a header row wider than the data under it.
21
+ yielder << CSV.generate_line(parse_header(header, projection))
21
22
 
22
23
  offset = 0
23
24
 
@@ -58,23 +59,14 @@ module ForestAdminAgent
58
59
  end
59
60
 
60
61
  # Parse header parameter into an array
61
- # @param header [String, Array, nil] Header as JSON string, array, or nil
62
- # @param projection [Projection] Field projection (fallback if header is invalid)
63
- # @return [Array<String>] Header fields
62
+ # @param header [String, Array, nil] Header as a comma-joined string, JSON array, array or nil
63
+ # @param projection [Projection] Field projection
64
+ # @return [Array<String>] The labels when there is exactly one per exported column, the field
65
+ # paths otherwise — a header that cannot be mapped onto the columns would mislabel them
64
66
  def self.parse_header(header, projection)
65
- case header
66
- when Array
67
- header
68
- when String
69
- return projection.to_a if header.empty?
67
+ labels = CsvGenerator.parse_header_labels(header)
70
68
 
71
- JSON.parse(header)
72
- else
73
- projection.to_a
74
- end
75
- rescue JSON::ParserError
76
- # Fallback to projection if JSON parsing fails
77
- projection.to_a
69
+ labels&.size == projection.size ? labels : projection.to_a
78
70
  end
79
71
 
80
72
  # Generate CSV row from record data
@@ -66,6 +66,22 @@ module ForestAdminAgent
66
66
  parse_projection_from_header(collection, args) || parse_projection(collection, args)
67
67
  end
68
68
 
69
+ # Both halves read the same `fields` param the same way on purpose: an empty
70
+ # `fields[<collection>]=` means "every column", so it is not named and must take the redaction
71
+ # path rather than a 403.
72
+ def self.parse_requested_projection(collection, args)
73
+ from_header = parse_projection_from_header(collection, args)
74
+
75
+ return { projection: from_header, named_by_caller: true } if from_header
76
+
77
+ fields = args.dig(:params, :fields, collection.name)
78
+
79
+ {
80
+ projection: parse_projection(collection, args),
81
+ named_by_caller: !(fields.nil? || fields == '')
82
+ }
83
+ end
84
+
69
85
  def self.add_polymorphic_type_fields(collection, requested_field_names)
70
86
  polymorphic_relations = collection.schema[:fields].select { |_, field| field.type == 'PolymorphicManyToOne' }
71
87
 
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.39.1"
9
+ LIANA_VERSION = "1.39.2"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.39.1"
2
+ VERSION = "1.39.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.39.1
4
+ version: 1.39.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-08-20 00:00:00.000000000 Z
12
+ date: 2026-08-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport