Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ def make_line(context, filter, args)
LineChart.new(result).serialize
end

# No record of an aggregation can be redacted afterwards, so each aggregated scope filters.
def intersect_scopes(context, filter, scoped)
scopes = scoped.map do |collection_name, prefix|
scope = context.permissions.get_scope(context.datasource.get_collection(collection_name))
prefix ? scope&.nest(prefix) : scope
end

filter.override(
condition_tree: ConditionTree::ConditionTreeFactory.intersect([filter.condition_tree, *scopes])
)
end

def make_leaderboard(context, filter, args)
field = context.collection.schema[:fields][args[:params][:relationshipFieldName]]

Expand All @@ -169,7 +181,7 @@ def make_leaderboard(context, filter, args)
)
if inverse
collection = field.foreign_collection
leaderboard_filter = filter.nest(inverse)
leaderboard_filter = intersect_scopes(context, filter.nest(inverse), [[field.foreign_collection, nil]])
aggregation = Aggregation.new(
operation: args[:params][:aggregator],
field: aggregate_field_name(args),
Expand All @@ -189,7 +201,9 @@ def make_leaderboard(context, filter, args)
)
if origin && target
collection = field.through_collection
leaderboard_filter = filter.nest(origin)
leaderboard_filter = intersect_scopes(
context, filter.nest(origin), [[field.through_collection, nil], [field.foreign_collection, target]]
)
aggregation = Aggregation.new(
operation: args[:params][:aggregator],
field: aggregate_field_name(args) ? "#{target}:#{aggregate_field_name(args)}" : nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ def handle_request(args = {})
filename_with_timestamp = filename.gsub('.csv', "_export_#{now}.csv")

# Return streaming enumerator instead of full CSV string
list_records = ->(batch_filter) { context.collection.list(context.caller, batch_filter, projection) }
relation_scopes = context.permissions.relation_scopes(context.collection, projection)
list_records = lambda do |batch_filter|
relation_scopes.redact(
context.collection.list(context.caller, batch_filter, relation_scopes.with_scope_fields(projection)),
projection,
context.caller.timezone
)
end

{
content: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ def handle_request(args = {})
.override(page: QueryStringParser.parse_pagination(args))

projection = redacted_projection_with_pks(context, context.collection, args)
records = context.collection.list(context.caller, filter, projection)
relation_scopes = context.permissions.relation_scopes(context.collection, projection)
records = relation_scopes.redact(
context.collection.list(context.caller, filter, relation_scopes.with_scope_fields(projection)),
projection,
context.caller.timezone
)

{
name: args[:params]['collection_name'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,19 @@ def handle_request(args = {})
filename_with_timestamp = "#{collection_name}_#{relation_name}_export_#{now}.csv"

# Create a callable to fetch related records
relation_scopes = context.permissions.relation_scopes(context.child_collection, projection)
list_records = lambda do |batch_filter|
ForestAdminDatasourceToolkit::Utils::Collection.list_relation(
context.collection,
primary_key_values,
relation_name,
context.caller,
batch_filter,
projection
relation_scopes.redact(
ForestAdminDatasourceToolkit::Utils::Collection.list_relation(
context.collection,
primary_key_values,
relation_name,
context.caller,
batch_filter,
relation_scopes.with_scope_fields(projection)
),
projection,
context.caller.timezone
)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ def handle_request(args = {})
.override(page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args))
projection = redacted_projection_with_pks(context, context.child_collection, args)
primary_key_values = Utils::Id.unpack_id(context.collection, args[:params]['id'], with_key: true)
records = Collection.list_relation(
context.collection,
primary_key_values,
args[:params]['relation_name'],
context.caller,
filter,
projection
relation_scopes = context.permissions.relation_scopes(context.child_collection, projection)
records = relation_scopes.redact(
Collection.list_relation(
context.collection,
primary_key_values,
args[:params]['relation_name'],
context.caller,
filter,
relation_scopes.with_scope_fields(projection)
),
projection,
context.caller.timezone
)

{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ def handle_request(args = {})

projection = redacted_projection_with_pks(context, context.collection, args)

records = context.collection.list(context.caller, filter, projection)
relation_scopes = context.permissions.relation_scopes(context.collection, projection)
records = relation_scopes.redact(
context.collection.list(context.caller, filter, relation_scopes.with_scope_fields(projection)),
projection,
context.caller.timezone
)

raise Http::Exceptions::NotFoundError, 'Record does not exists' unless records.size.positive?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module ForestAdminAgent
module Services
class Permissions
TO_ONE_RELATION_TYPES = %w[ManyToOne OneToOne].freeze

include ForestAdminAgent::Http::Exceptions
include ForestAdminAgent::Utils
include ForestAdminDatasourceToolkit::Exceptions
Expand Down Expand Up @@ -292,6 +294,11 @@ def get_scope(collection)
ContextVariablesInjector.inject_context_in_filter(scope, context_variables)
end

# The caller's scope on each collection the projection reaches through a to-one relation.
def relation_scopes(collection, projection)
Utils::RelationScopes.new(collect_relation_scopes(collection, projection))
end

def get_segments(collection, force_fetch: false)
permissions = get_rendering_data(caller.rendering_id, force_fetch: force_fetch)

Expand All @@ -313,6 +320,20 @@ def get_team(rendering_id)

private

def collect_relation_scopes(collection, projection, prefix = nil)
projection.relations.flat_map do |name, sub_projection|
relation = collection.schema[:fields][name]
next [] unless TO_ONE_RELATION_TYPES.include?(relation.type)

foreign_collection = collection.datasource.get_collection(relation.foreign_collection)
path = prefix ? "#{prefix}:#{name}" : name
scope = get_scope(foreign_collection)
nested = collect_relation_scopes(foreign_collection, sub_projection, path)

scope ? [Utils::RelationScopes::RelationScope.new(path, foreign_collection, scope), *nested] : nested
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 8): collect_relation_scopes [qlty:function-complexity]

end

def cached(store, key, &fetch)
@memo.fetch([store, key]) { @memo[[store, key]] = store.get_or_set(key, &fetch) }
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module ForestAdminAgent
module Utils
# The scope of a collection reached through a to-one relation empties the related record rather
# than filtering the root rows: a scope on `accounts` restricts which accounts are seen, never
# which `cards` exist.
class RelationScopes
RelationScope = Struct.new(:path, :collection, :scope)

def initialize(scopes = [])
@scopes = scopes
end

def with_scope_fields(projection)
scope_fields = @scopes.map { |relation| relation.scope.projection.nest(prefix: relation.path) }

projection.union(*scope_fields)
end

def redact(records, projection, timezone)
return records if @scopes.empty?

out_of_scope_paths = records.map { |record| out_of_scope_paths(record, timezone) }

projection.apply(records).each_with_index do |record, index|
out_of_scope_paths[index].each { |path| self.class.clear(record, path) }
end
end

def self.clear(record, path)
relation, rest = path.split(':', 2)

if rest.nil?
record[relation] = nil
elsif record[relation]
clear(record[relation], rest)
end
end

private

def out_of_scope_paths(record, timezone)
stringified = ForestAdminDatasourceToolkit::Utils::HashHelper.convert_keys(record, :to_s)

@scopes.filter_map do |relation_scope|
related = ForestAdminDatasourceToolkit::Utils::Record.field_value(stringified, relation_scope.path)

relation_scope.path if related && !relation_scope.scope.match(related, relation_scope.collection, timezone)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High utils/relation_scopes.rb:47

A related record with a NULL value for a scoped comparison field crashes the read or export request instead of being redacted; for example, balance > 0 reaches nil > 0 in scope.match. The related check only excludes an absent relation, so handle null comparison operands before invoking the matcher.

Also found in 1 other location(s)

packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb:299

The scopes collected here expose a crash in the unchanged toolkit matcher (components/query/condition_tree/nodes/condition_tree_leaf.rb, match): its less_than/greater_than branches compare field_value directly, without handling nulls. For example, a related account scoped by balance > 0 with a null balance now reaches nil > 0 via RelationScopes#redact. The latter only guards against the entire related record being absent, so one such account crashes the whole list/show request or aborts the CSV export rather than redacting the account. Handle nullable comparison operands before using this matcher for related scopes.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_agent/lib/forest_admin_agent/utils/relation_scopes.rb around line 47:

A related record with a `NULL` value for a scoped comparison field crashes the read or export request instead of being redacted; for example, `balance > 0` reaches `nil > 0` in `scope.match`. The `related` check only excludes an absent relation, so handle null comparison operands before invoking the matcher.

Also found in 1 other location(s):
- packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb:299 -- The scopes collected here expose a crash in the unchanged toolkit matcher (`components/query/condition_tree/nodes/condition_tree_leaf.rb`, `match`): its `less_than`/`greater_than` branches compare `field_value` directly, without handling nulls. For example, a related account scoped by `balance > 0` with a null balance now reaches `nil > 0` via `RelationScopes#redact`. The latter only guards against the entire related record being absent, so one such account crashes the whole list/show request or aborts the CSV export rather than redacting the account. Handle nullable comparison operands before using this matcher for related scopes.

end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,57 @@ module Charts
)
end

describe 'with scopes on the aggregated collections' do
let(:scopes) do
{
'review' => ConditionTreeLeaf.new('author', Operators::EQUAL, 'Isaac Asimov'),
'book_review' => ConditionTreeLeaf.new('review_id', Operators::EQUAL, 7)
}
end

before do
allow(permissions).to receive(:get_scope) { |collection| scopes[collection.name] }
allow(@datasource.get_collection('book')).to receive(:datasource).and_return(@datasource)
end

def aggregated_condition_tree(collection_name)
captured = nil
allow(@datasource.get_collection(collection_name)).to receive(:aggregate) do |_caller, filter, *|
captured = filter
[]
end
chart.handle_request(args)

captured.condition_tree.to_h
end

it 'applies the scope of the aggregated OneToMany collection' do
args[:params] = args[:params].merge({ labelFieldName: 'title', relationshipFieldName: 'bookReviews',
aggregator: 'Count', aggregateFieldName: 'id',
sourceCollectionName: 'book', type: 'Leaderboard' })

expect(aggregated_condition_tree('review')).to eq(
{ field: 'author', operator: Operators::EQUAL, value: 'Isaac Asimov' }
)
end

it 'applies the scopes of the through and foreign collections of a ManyToMany' do
args[:params] = args[:params].merge({ labelFieldName: 'year', relationshipFieldName: 'reviews',
aggregator: 'Count', aggregateFieldName: 'id',
sourceCollectionName: 'book', type: 'Leaderboard' })

expect(aggregated_condition_tree('book_review')).to eq(
{
aggregator: 'And',
conditions: [
{ field: 'review_id', operator: Operators::EQUAL, value: 7 },
{ field: 'review:author', operator: Operators::EQUAL, value: 'Isaac Asimov' }
]
}
)
end
end

# A count names no path back to the collection it counts, so nothing above sees it. An empty
# `aggregateFieldName` is a count too: `can_chart?` cannot tell it from an absent one.
it 'asserts browse on the counted collection when a OneToMany count names no field' do
Expand Down
Loading
Loading