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 +4 -4
- data/lib/forest_admin_agent/routes/abstract_authenticated_route.rb +20 -0
- data/lib/forest_admin_agent/routes/charts/charts.rb +61 -11
- data/lib/forest_admin_agent/routes/resources/count.rb +13 -3
- data/lib/forest_admin_agent/routes/resources/csv.rb +21 -8
- data/lib/forest_admin_agent/routes/resources/list.rb +14 -5
- data/lib/forest_admin_agent/routes/resources/related/csv_related.rb +16 -6
- data/lib/forest_admin_agent/routes/resources/related/list_related.rb +10 -7
- data/lib/forest_admin_agent/routes/resources/show.rb +1 -1
- data/lib/forest_admin_agent/routes/resources/store.rb +2 -1
- data/lib/forest_admin_agent/routes/resources/update.rb +2 -1
- data/lib/forest_admin_agent/routes/resources/update_field.rb +4 -2
- data/lib/forest_admin_agent/services/permissions.rb +174 -0
- data/lib/forest_admin_agent/utils/csv_generator.rb +40 -0
- data/lib/forest_admin_agent/utils/csv_generator_stream.rb +9 -17
- data/lib/forest_admin_agent/utils/query_string_parser.rb +16 -0
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5002fa757fc6f7be665d7b966a47e8d7f69c600b0061092fc4ae0d9cd25ff2c7
|
|
4
|
+
data.tar.gz: efd52bb2fcc984e3cf589b3933cc5876c8f93071daad61084435fa71f50f276b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
37
|
+
condition_tree
|
|
28
38
|
]
|
|
29
39
|
),
|
|
30
|
-
search:
|
|
31
|
-
search_extended:
|
|
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
|
-
|
|
31
|
-
context.collection, args
|
|
32
|
-
)
|
|
40
|
+
condition_tree
|
|
33
41
|
]
|
|
34
42
|
),
|
|
35
|
-
search:
|
|
36
|
-
search_extended:
|
|
37
|
-
sort:
|
|
43
|
+
search: search,
|
|
44
|
+
search_extended: search_extended,
|
|
45
|
+
sort: sort,
|
|
38
46
|
segment: QueryStringParser.parse_segment(context.collection, args)
|
|
39
47
|
)
|
|
40
|
-
|
|
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
|
-
|
|
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:
|
|
31
|
-
search_extended:
|
|
32
|
-
sort:
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
39
|
+
sort: sort
|
|
36
40
|
)
|
|
37
|
-
projection =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
62
|
-
# @param projection [Projection] Field projection
|
|
63
|
-
# @return [Array<String>]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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-
|
|
12
|
+
date: 2026-08-25 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|