Skip to content
Merged
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 @@ -330,11 +330,16 @@ def usage(action, collection, path)
end

def collect_search_usages(collection, search, search_extended, usages)
return if search.nil? || !collection.respond_to?(:searched_fields)
# The stack discards a blank search instead of running it, so there is nothing to authorize.
return if search.nil? || search.strip.empty?

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 services/permissions.rb:334

A numeric search value such as 1234 raises NoMethodError in collect_search_usages instead of reaching the searchable collection or producing the route's request error. QueryStringParser.parse_search deliberately preserves non-string values, so only apply the blank-search check to strings.

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

A numeric `search` value such as `1234` raises `NoMethodError` in `collect_search_usages` instead of reaching the searchable collection or producing the route's request error. `QueryStringParser.parse_search` deliberately preserves non-string values, so only apply the blank-search check to strings.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Real bug, fixed at the source instead.

.strip is applied in three places — permissions.rb:334, search_collection_decorator.rb:36 and :103 — so guarding only the first would move the NoMethodError one layer down rather than remove it. parse_search now coerces the term and rejects a value that cannot be one (array, hash, boolean) with a BadRequestError, which closes all three.

On the premise: the spec pinning parse_search returning 1234 is titled "converts the query search parameter as string", so preserving the Integer was a bug its own name contradicted rather than a deliberate contract. The assertion now matches the title.

Fixed in ca75407.


searched = collection.searched_fields(search, search_extended)
searched = collection.searched_fields(search, search_extended) if collection.respond_to?(:searched_fields)

return if searched.nil?
if searched.nil?
assert_extended_search_checkable(collection, search_extended)

return
end

published = collection.datasource.collections

Expand All @@ -344,6 +349,25 @@ def collect_search_usages(collection, search, search_extended, usages)
end
end

# Only a callable `replace_search` is a footprint this stack could have described and did not; a
# native child search, or no search decorator at all, builds no condition here. A plain search is
# served in either case — the extended flag is the caller's own, so the exemption stops there.
def assert_extended_search_checkable(collection, search_extended)
return unless search_extended
return unless describes_own_search?(collection)
return unless permission_system?

raise ForbiddenError,
"You cannot run an extended search on the '#{collection.name}' collection: the fields " \
'it reaches cannot be determined, so they cannot be checked against your permissions.'
end

# Reaches the search decorator only through `CollectionDecorator#search_handler?`: drop that
# delegation and this answers false for every collection.
def describes_own_search?(collection)
collection.respond_to?(:search_handler?) && collection.search_handler?
end

# `searched_fields` answers below the publication layer — deliberately, so a field hidden by
# renaming above it is still checked — so it can name a collection `remove_collection` took out
# of the API. An extended search does reach through to it: the condition is built below
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class QueryStringParser
DEFAULT_ITEMS_PER_PAGE = '15'.freeze
DEFAULT_PAGE_TO_SKIP = '1'.freeze
POLYMORPHIC_TARGET_WILDCARD = '*'.freeze
FALSY_SEARCH_EXTENDED = [nil, false, 0, '0', 'false', ''].freeze

def self.parse_condition_tree(collection, args)
filters = begin
Expand Down Expand Up @@ -230,21 +231,43 @@ def self.parse_export_pagination(limit)
Page.new(offset: 0, limit: limit&.to_i)
end

# Presence, not truth: with +||+ a +false+ in the select-all body would silently lose to the
# query string. An explicit +null+ or +''+ there is read as absent rather than as "no search",
# so neither can widen a result set by discarding the term the URL carried.
def self.subset_or_query(args, key)
subset = begin
args.dig(:params, :data, :attributes, :all_records_subset_query)
rescue StandardError
nil
end

return subset[key] if subset.is_a?(Hash) && !subset[key].nil? && subset[key] != ''

begin
args.dig(:params, key)
rescue StandardError
nil
end
end

def self.parse_search(collection, args)
search = args.dig(:params, :data, :attributes, :all_records_subset_query, :search) || args.dig(:params, :search)
search = subset_or_query(args, :search)

return nil if search.nil?

raise BadRequestError, 'Collection is not searchable' if search && !collection.is_searchable?
raise BadRequestError, 'Collection is not searchable' unless collection.is_searchable?

search
unless search.is_a?(String) || search.is_a?(Numeric)
raise BadRequestError, 'Search must be a string or a number'
end

search.to_s
Comment thread
qltysh[bot] marked this conversation as resolved.
end

def self.parse_search_extended(args)
extended = args.dig(:params, :data, :attributes, :all_records_subset_query,
:searchExtended) || args.dig(:params, :searchExtended)

return false if extended.nil?
extended = subset_or_query(args, :searchExtended)

extended != '0'
!FALSY_SEARCH_EXTENDED.include?(extended.is_a?(String) ? extended.downcase : extended)
end

def self.parse_sort(collection, args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ module Resources
ForestAdminAgent::Facades::Container.datasource.get_collection('user').enable_count
count.handle_request(args)

expect(read_guard_calls[:query_fields]).to eq([{ collection: 'user', applies: %i[filter search] }])
expect(read_guard_calls[:query_fields]).to eq([{ collection: 'user', applies: %i[filter search search_extended] }])
end

it 'hands the guard the extended flag it parsed, not a default' do
ForestAdminAgent::Facades::Container.datasource.get_collection('user').enable_count
args[:params][:searchExtended] = '1'
count.handle_request(args)

expect(read_guard_calls[:search_extended]).to eq([true])
end

context 'when collection is countable' do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,18 @@ module Resources
csv.handle_request(args)

expect(read_guard_calls[:query_fields]).to eq(
[{ collection: 'user', applies: %i[filter sort search] }]
[{ collection: 'user', applies: %i[filter sort search search_extended] }]
)
expect(read_guard_calls[:projections]).to eq([{ collection: 'user', named_by_caller: false }])
end

it 'hands the guard the extended flag it parsed, not a default' do
args[:params][:searchExtended] = '1'
csv.handle_request(args)

expect(read_guard_calls[:search_extended]).to eq([true])
end

context 'when call csv' do
it 'returns a streaming export csv' do
# Create a mock enumerator that yields CSV data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,17 @@ module Resources
list.handle_request(args)

expect(read_guard_calls[:query_fields]).to eq(
[{ collection: 'user', applies: %i[filter sort search] }]
[{ collection: 'user', applies: %i[filter sort search search_extended] }]
)
end

it 'hands the guard the extended flag it parsed, not a default' do
args[:params][:searchExtended] = '1'
list.handle_request(args)

expect(read_guard_calls[:search_extended]).to eq([true])
end

it 'refuses a projection the caller named on its own collection' do
args[:params][:fields] = { 'user' => 'id,first_name' }
list.handle_request(args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,13 @@ def leaf(field)
Nodes::ConditionTreeLeaf.new(field, Operators::EQUAL, 'FR76')
end

def searchable_cards(searched)
def searchable_cards(searched, search_handler: false)
double = instance_double(
ForestAdminDatasourceToolkit::Decorators::CollectionDecorator,
ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator,
name: 'cards',
datasource: datasource
)
allow(double).to receive(:searched_fields).and_return(searched)
allow(double).to receive_messages(searched_fields: searched, search_handler?: search_handler)

double
end
Expand Down Expand Up @@ -353,12 +353,46 @@ def searchable_cards(searched)
)
end

# A replaced search: the handler picks the fields, the caller only supplies the text.
# A replaced search: the block picks the fields, the caller only supplies the text.
it 'serves the request when the stack cannot say what a search reaches' do
permissions = build_permissions([])

expect { permissions.assert_can_read_query_fields(searchable_cards(nil), search: 'martin') }
.not_to raise_error
expect do
permissions.assert_can_read_query_fields(
searchable_cards(nil, search_handler: true), search: 'martin'
)
end.not_to raise_error
end

# The flag is the caller's: the same term with it off and on differs by exactly the rows
# matched through a relation, so an unverifiable traversal is refused where a plain search is
# served.
it 'refuses the extended half of that same search' do
permissions = build_permissions([])

expect do
permissions.assert_can_read_query_fields(
searchable_cards(nil, search_handler: true), search: 'martin', search_extended: true
)
end.to raise_error(
ForestAdminAgent::Http::Exceptions::ForbiddenError,
"You cannot run an extended search on the 'cards' collection: the fields it reaches " \
'cannot be determined, so they cannot be checked against your permissions.'
)
end

# `refine_filter` discards a blank search instead of running it, so refusing one would 403 a
# request that searches nothing.
['', ' '].each do |blank|
it "serves #{blank.inspect} with the extended flag on, which runs no search at all" do
permissions = build_permissions([])

expect do
permissions.assert_can_read_query_fields(
searchable_cards(nil, search_handler: true), search: blank, search_extended: true
)
end.not_to raise_error
end
end

it 'checks nothing on a collection that cannot answer what a search reaches' do
Expand All @@ -367,6 +401,24 @@ def searchable_cards(searched)
expect { permissions.assert_can_read_query_fields(cards, search: 'martin') }.not_to raise_error
end

# Refusing here would 403 every collection the search decorator does not sit on.
it 'serves the extended search of a collection that cannot answer at all' do
permissions = build_permissions([])

expect { permissions.assert_can_read_query_fields(cards, search: 'martin', search_extended: true) }
.not_to raise_error
end

# `permission_system?` fetches `/liana/v4/permissions/environment` cold; `describes_own_search?`
# is local. Every extended search on a nil footprint reached that fetch.
it 'does not reach the permission system for a search it will not refuse' do
permissions = build_permissions([])

permissions.assert_can_read_query_fields(cards, search: 'martin', search_extended: true)

expect(permissions).not_to have_received(:permission_system?)
end

it 'accepts a filter once the collection it reaches is readable' do
permissions = build_permissions(%w[accounts])

Expand All @@ -381,6 +433,125 @@ def searchable_cards(searched)

expect { permissions.assert_can_read_query_fields(cards) }.not_to raise_error
end

# The examples above hand the guard a stubbed footprint to pin its policy. These drive a real
# search decorator instead, so what the decorator reports and what the guard does with it are
# checked together.
describe 'through a real replace_search field selection' do
def cards_searching(replacer)
decorator = ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator.new(
cards, datasource
)
decorator.replace_search(replacer)

decorator
end

it 'refuses an included relation path the caller cannot read' do
permissions = build_permissions([])
collection = cards_searching({ include_fields: ['account:iban'] })

expect { permissions.assert_can_read_query_fields(collection, search: 'FR76') }
.to raise_error(
ForestAdminAgent::Http::Exceptions::ForbiddenError,
"You cannot search on 'account:iban': you are not allowed to read the 'accounts' collection."
)
end

it 'serves the same search once the collection the path reaches is readable' do
permissions = build_permissions(%w[accounts])
collection = cards_searching({ include_fields: ['account:iban'] })

expect { permissions.assert_can_read_query_fields(collection, search: 'FR76') }.not_to raise_error
end

# What a field selection buys over a callable: the callable names no field, so a plain search
# reads the same column unchecked for a role that cannot read the collection it belongs to,
# and its extended half is refused outright rather than checked.
it 'serves a plain search a callable describes, which names no field to check' do
permissions = build_permissions([])
collection = cards_searching(
->(value, _extended, _context) { { field: 'account:iban', operator: Operators::EQUAL, value: value } }
)

expect { permissions.assert_can_read_query_fields(collection, search: 'FR76') }.not_to raise_error
end

it 'refuses the extended search of that callable, where the selection is checked instead' do
permissions = build_permissions(%w[accounts])
collection = cards_searching(
->(value, _extended, _context) { { field: 'account:iban', operator: Operators::EQUAL, value: value } }
)

expect do
permissions.assert_can_read_query_fields(collection, search: 'FR76', search_extended: true)
end.to raise_error(
ForestAdminAgent::Http::Exceptions::ForbiddenError,
/You cannot run an extended search on the 'cards' collection/
)
end

it 'serves the extended search of the equivalent field selection' do
permissions = build_permissions(%w[accounts])
collection = cards_searching({ include_fields: ['account:iban'] })

expect do
permissions.assert_can_read_query_fields(collection, search: 'FR76', search_extended: true)
end.not_to raise_error
end

# Refusing here would take extended search off every natively searchable datasource.
it 'serves an extended search the child collection runs natively' do
permissions = build_permissions([])
native = datasource.get_collection('cards')
allow(native).to receive(:schema).and_return(native.schema.merge(searchable: true))
collection = ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator.new(
native, datasource
)

expect(collection.searched_fields('martin', true)).to be_nil
expect do
permissions.assert_can_read_query_fields(collection, search: 'martin', search_extended: true)
end.not_to raise_error
end

# The only example that goes through the stack the routes hand the guard: without it, the
# delegation going missing looks exactly like the refusal being correctly narrowed.
it 'refuses a callable extended search through a booted customizer stack' do
permissions = build_permissions([])
customizer = ForestAdminDatasourceCustomizer::DatasourceCustomizer.new
customizer.add_datasource(datasource, {})
customizer.customize_collection('cards') do |collection|
collection.replace_search do |value, _extended, _context|
{ field: 'pan_last4', operator: Operators::EQUAL, value: value }
end
end
top = customizer.datasource({}).get_collection('cards')

expect(top).not_to be_a(ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator)
expect(top.searched_fields('martin', true)).to be_nil
expect do
permissions.assert_can_read_query_fields(top, search: 'martin', search_extended: true)
end.to raise_error(
ForestAdminAgent::Http::Exceptions::ForbiddenError,
/You cannot run an extended search on the 'cards' collection/
)
end

# `can?` allows everything without a permission system, so a refusal there would be the one
# denial no grant could lift.
it 'serves the callable extended search when no permission system is enabled' do
permissions = described_class.new(caller)
allow(permissions).to receive(:permission_system?).and_return(false)
collection = cards_searching(
->(value, _extended, _context) { { field: 'pan_last4', operator: Operators::EQUAL, value: value } }
)

expect do
permissions.assert_can_read_query_fields(collection, search: 'FR76', search_extended: true)
end.not_to raise_error
end
end
end

describe '#read_permissions' do
Expand Down
Loading
Loading