Skip to content

feat(datasource-customizer): let replace_search take a field selection so a narrowed search is permission-checked - #382

Merged
matthv merged 13 commits into
mainfrom
feat/prd-1078-replace-search-field-selection
Sep 2, 2026
Merged

feat(datasource-customizer): let replace_search take a field selection so a narrowed search is permission-checked#382
matthv merged 13 commits into
mainfrom
feat/prd-1078-replace-search-field-selection

Conversation

@PMerlet

@PMerlet PMerlet commented Aug 28, 2026

Copy link
Copy Markdown
Member

fixes PRD-1078

Ports agent-nodejs #1852 and the refusal it narrows, #1840, so ruby ends up with node's search-permission behaviour rather than half of it.

Why

replace_search took a block only. A block picks its own fields, so searched_fields answers nil and collect_search_usages returned without checking anything — a customized search was read entirely unchecked, extended included.

That is not the same gap node had, and it is worth stating plainly because it inverts the framing. In node, getSearchedFields returning null already caused an extended search to be refused; #1852 exists to un-refuse the narrowable case. In ruby nothing was refused and nothing was checked. So this PR does both halves:

plain extended
no replace_search checked checked per path
replace_search(block) served, exempt refused, where a permission system is enabled
replace_search(include_fields:) checked → 403 by name (was: unchecked) checked → 403 by name
no replace_search, natively searchable child served by the datasource served — the agent builds no condition, so it has nothing to check

The check is at collection granularity — there are no field-level permissions — so a selection naming only root columns can never 403; only relation paths can.

What

replace_search now also takes a field selection:

collection.replace_search(include_fields: ['project:name'], exclude_fields: ['description'])

Keywords rather than a hash, so a misspelt key raises where a hash would have carried it silently — the runtime equivalent of what tsc gives node. A block and a selection together, or neither, are refused rather than resolved by precedence.

extended is deliberately not a key: that flag is the caller's and comes from the request, so a customization must not pin it.

The part worth reviewing

Both derivations are unified behind searchable_fields, which refine_filter and searched_fields now share. A path the search reads without appearing in the footprint is a column read unchecked, so the two must not be allowed to drift. That is pinned in four specs against the paths the generated condition tree actually reads — include / exclude / only, plus a numeric term that matches more columns than a word.

It is also structural rather than only tested: enumerable_search? is handler.nil? && implements_search?, and implements_search? is the very predicate refine_filter branches on. The condition is spelt once.

Strict field-name resolution

Names resolve through Utils::Collection.get_field_schema, raising when the customization is applied. Node drops an unresolvable name silently, which empties the searchable set — that was raised in review on #1852 and left as pre-existing there; ruby has no such legacy. Resolution also refuses a path crossing anything but a to-one relation, so a polymorphic one is reported instead of silently skipped.

Found by reviewing this branch adversarially

Probing the implementation rather than re-reading it turned up one sharp bug, in the second commit's own feature surface:

A search with no searchable field returned every row instead of none. union([]) answers nil, and intersect drops a nil branch, so the filter came back carrying neither a condition nor a search. A customization meant to narrow widened the result to everything — reachable as only_fields: [], or exclude_fields covering every searchable column.

It was reachable with no customization at all too, on a collection whose columns are all unsearchable, and two existing specs pinned it while contradicting themselves in their own titles:

it 'adds a condition to not return record if it is the only one filter'
  expect(refined_filter).to have_attributes(condition_tree: nil)

nil is not "return no record", it is "no restriction". Both now assert match_none, which is what their names always claimed, what the enum-value-not-found case a few lines above already returned, and what node returns here.

What this actually changes (the paragraph here previously claimed such a search returned every row, which it did not): ConditionTreeFactory.group compacts before it counts, so the old .map already produced ConditionTreeBranch('Or', []) whenever a term matched no column — and EmptyCollectionDecorator, which sits below this layer and intercepts list / aggregate / update / delete, already reads an empty Or as match-none. So 0 rows came back on main too.

The commit replaces nil with an explicit match_none and states the intent in code rather than in a comment. Behaviour changes only where searchable_fields is itself empty — only_fields: [], or an exclude_fields covering every searchable column — where union([]) answered nil and intersect dropped it, widening the result to everything. Keep it: filter_map without the match_none line would be a genuine regression, and the same commit carries the String normalisation of paths and implements_search?.

Worth knowing that the safety lives in EmptyCollectionDecorator, not in the tree: the datasource translators still mistranslate an empty branch (ActiveRecord emits no WHERE, Snowflake the same, Mongoid emits $or: [] which the server rejects; Hasura is correct). Nothing reaches them through the decorator stack, but that is a latent trap and it is being tracked separately.

Also from the same pass: a symbol field name raised NoMethodError: undefined method 'include?' for an instance of Symbol from inside the toolkit, defeating the point of resolving strictly to get a clear message. Paths are normalised to strings.

Breaking in practice

An agent whose collection installs a replace_search block now answers 403 on an extended search there, where it was served. Converting that block to a field selection restores it, checked per path.

The refusal is scoped to a callable replacer. A natively searchable child — Zendesk declares one per collection, RPC mirrors whatever the remote agent declares — and a collection with no search decorator at all are served as before: they answer nil because nothing in this stack builds the condition, not because a customization chose the fields.

Shipped as a fix rather than a major bump, matching how node shipped the same policy (fix(agent): refuse an extended search the stack cannot describe).

Not in scope

  • Inferring a block's footprint. It would mean running customer code twice or authorizing after refine_filter — out of proportion, same conclusion as node.
  • An extended search on an RPC-backed collection reaches the remote agent, whose own search decorator walks to-one relations, and the RPC agent has no permission layer. Pre-existing and unchanged here; the main agent cannot enumerate what it does not build.
  • refine_filter(caller, nil) raises NoMethodError: the guard evaluates filter.override when filter is nil. Pre-existing, node has filter?.override. Untouched here because it is unrelated to this ticket and fixing it changes a behaviour (nil vs raise).

Verified as CI runs it

  • bundle exec rubocop at the root: 840 files, no offenses
  • BUNDLE_GEMFILE=Gemfile-test bundle exec rspec: forest_admin_datasource_customizer 724, forest_admin_agent 1179, forest_admin_datasource_toolkit 478, forest_admin_datasource_rpc 168 — 0 failures

Every new guard was mutation-checked: disabling the extended-search refusal fails 3 specs, and reverting enumerable_search? fails the end-to-end permission spec, so none of them pass vacuously.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

🤖 Generated with Claude Code

Note

Let replace_search accept field selections and permission-check narrowed searches

  • Extends replace_search DSL to accept field-selection arguments (include_fields, exclude_fields, only_fields) alongside the existing block form, with validation that rejects mixing block and selections, empty lists, or combining only_fields with include/exclude.
  • The SearchCollectionDecorator now resolves and validates field selections at configuration time, builds a condition tree from the selected searchable fields, and reports accurate field footprints to the permissions layer via searched_fields.
  • The permissions layer (Permissions.collect_search_usages) uses the footprint to permission-check selection-based extended searches; when a collection has a custom callable handler (search_handler?) and a permission system is enabled, extended searches raise ForbiddenError since fields cannot be enumerated.
  • parse_search now rejects non-string/numeric search values with BadRequestError and coerces numeric values to strings; parse_search_extended correctly treats false, 0, 'false', and empty string as falsy and lets action payload values override query string values.
  • Risk: selection-based searches that match no searchable fields now return a 'match none' condition tree instead of letting all records through; collections with callable search handlers and permissions enabled can no longer run extended searches.

Changes since #382 opened

  • Changed ForestAdminAgent::Utils::QueryStringParser.parse_search to prioritize body search values over query string parameters, validate non-nil search values for searchability, and reject non-String/non-Numeric types including booleans [7f7bf0b]
  • Reordered guard clauses in ForestAdminAgent::Services::Permissions.assert_extended_search_checkable to check describes_own_search? before permission_system? [7f7bf0b]
  • Modified ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator.searchable_fields to exclude entire relation trees when a relation name appears in exclude_fields, rather than only exact field name matches, and added private helper methods excluded? and excluded_field to support hierarchical path exclusion [b692b1f]
  • Relaxed validation in ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator.assert_selection_resolves to accept exclude_fields entries that exist as field schemas without requiring them to be searchable or strictly resolvable [b692b1f]
  • Added warning log in ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator.replace_search when a callable handler is configured, implemented via new private method warn_extended_search_refused [b692b1f]
  • Updated inline documentation in ForestAdminDatasourceCustomizer::CollectionCustomizer to clarify name resolution order, interaction with only_fields, and extended search behavior with included relations [b692b1f]
  • Added test coverage in searched_fields_spec.rb for warning logging with callable handlers, validation of non-searchable excluded fields, and hierarchical relation exclusion behavior [b692b1f]
  • Added field selection validation to ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator [d3378a3]
  • Refactored search parameter resolution in ForestAdminAgent::Utils::QueryStringParser [d3378a3]
  • Expanded test coverage across forest_admin_agent and forest_admin_datasource_customizer packages [d3378a3]
  • Changed ForestAdminDatasourceCustomizer::Decorators::Search::SearchCollectionDecorator.excluded_field to log a debug message instead of raising an exception when excluding a field that the search does not read, and updated both excluded_field and get_fields methods to use safe navigation when accessing the logger to prevent errors when no logger is configured [fc2ffa4]
  • Modified ForestAdminAgent::Utils::QueryStringParser.subset_or_query to treat empty strings in the subset query as absent values, allowing query string parameters to take precedence [fc2ffa4]

Macroscope summarized ca75407.

@linear-code

linear-code Bot commented Aug 28, 2026

Copy link
Copy Markdown

PRD-1078

@qltysh

qltysh Bot commented Aug 28, 2026

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Structure High total complexity (count = 55) 1
qlty Structure Function with many parameters (count = 4): replace_search 1
qlty Structure Function with high complexity (count = 5): assert_search_replacement 1

# Example:
# collection.replace_search(include_fields: ['project:name'], exclude_fields: ['description'])
# collection.replace_search { |value, _extended, _context| { field: 'name', operator: Operators::CONTAINS, value: value } }
def replace_search(include_fields: nil, exclude_fields: nil, only_fields: nil, &definition)

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 many parameters (count = 4): replace_search [qlty:function-parameters]

Comment thread packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb Outdated
@qltysh

qltysh Bot commented Aug 28, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.6%.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...est_admin_agent/lib/forest_admin_agent/services/permissions.rb100.0%
Coverage rating: A Coverage rating: A
...ce_customizer/decorators/search/search_collection_decorator.rb98.6%74
Coverage rating: A Coverage rating: A
...ib/forest_admin_datasource_customizer/collection_customizer.rb100.0%
Coverage rating: F Coverage rating: F
...st_admin_datasource_toolkit/decorators/collection_decorator.rb100.0%
Coverage rating: A Coverage rating: A
...dmin_agent/lib/forest_admin_agent/utils/query_string_parser.rb93.3%249
Total98.2%
🤖 Increase coverage with AI coding...
In the `feat/prd-1078-replace-search-field-selection` branch, add test coverage for this new code:

- `packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb` -- Line 249
- `packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb` -- Line 74

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

PMerlet added a commit that referenced this pull request Aug 28, 2026
…on, and stop 403ing a blank search

Both found by Macroscope on #382, both confirmed by probing rather than reading.

`only_fields: ['holder']` passed validation and then raised
`NoMethodError: undefined method 'column_type' for ManyToOneSchema`, from
`searched_fields` and from `refine_filter` alike. `get_field_schema` only
requires a to-one relation of the segments it *crosses*; it happily returns the
relation itself when it is the last one, so the assumption that resolution
guarantees a column was wrong. `resolved_field` now rejects a non-column leaf and
names the type it found, at customization time like every other bad name.

`collect_search_usages` refused `?search=&searchExtended=1`: a blank search is
not nil, so it reached the footprint check and 403'd on a collection whose
footprint is unknown — a request that runs no search at all. `refine_filter`
discards a blank search rather than running it, so there is nothing to
authorize; the guard now matches, whitespace included. node skips the empty
string here because `''` is falsy in JS, but not `'   '`, so this is slightly
tighter than node rather than a port of it.

Both guards are mutation-checked: each fails exactly its own spec when disabled.

Left as is: qlty flags `replace_search` for having four parameters. The three
keywords are the point — they are what makes a misspelt key raise, which a hash
or `**options` would swallow, and a spec pins that. Four is the count of a
deliberate signature, not of a smell.

Verified as CI runs it: root rubocop 840 files / no offenses; Gemfile-test rspec
on forest_admin_agent 1180, forest_admin_datasource_customizer 725,
forest_admin_datasource_toolkit 478 — 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@matthv matthv self-assigned this Aug 31, 2026
PMerlet and others added 6 commits August 31, 2026 15:45
…n so a narrowed search is checked

fixes PRD-1078

`replace_search` took a block only. A block picks its own fields, so
`searched_fields` answers nil and `collect_search_usages` returns without
checking anything — a customized search is read entirely unchecked.

It now also takes a field selection:

    collection.replace_search(include_fields: ['project:name'], exclude_fields: ['description'])

A selection is a declarative list, so the footprint is computable. It is
reported and checked per path, and a path the role may not read is refused by
name. `extended` is deliberately not a key: that flag is the caller's and comes
from the request. Keywords rather than a hash, so a misspelt key raises where a
hash would have carried it silently; a block and a selection together, or
neither, are refused rather than resolved by precedence.

Both derivations are unified behind `searchable_fields`, which `refine_filter`
and `searched_fields` now share — a path the search reads without appearing in
the footprint is a column read unchecked, so the two cannot be allowed to drift.
Four specs pin that invariant against the paths the generated condition tree
actually reads, for include/exclude/only and for a numeric term that matches
more columns than a word.

Field names resolve strictly, through `Utils::Collection.get_field_schema`,
raising when the customization is applied. Node drops an unresolvable name
silently, which empties the searchable set and leaves the search matching
nothing for good; that was raised in review there and left as pre-existing.
Resolution also refuses a path crossing anything but a to-one relation, so a
polymorphic one is reported rather than silently skipped.

`enumerable_search?` now tracks the condition `refine_filter` tests rather than
approximating it: the footprint is knowable whenever this layer builds the tree
itself, which includes a field selection on a natively searchable collection —
there the selection replaces the native search rather than narrowing it, and the
customizer doc says so.

Not in scope: ruby still serves an unknown footprint silently, including an
extended search, where node refuses it. That parity gap would refuse requests
that work today for every agent using `replace_search`, so it needs its own
ticket rather than riding along here.

Verified: forest_admin_datasource_customizer 721 examples / 0 failures,
forest_admin_agent 1175 examples / 0 failures, rubocop clean on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fixes PRD-1078

`collect_search_usages` returned as soon as `searched_fields` answered nil, so a
`replace_search` block's search was read entirely unchecked — the extended half
included. node refuses that case (agent-nodejs#1840); ruby served it.

An unknown footprint stays served on a plain search: the caller supplied only the
text and aimed at nothing, the same category as a scope, and refusing would
remove search from every customized collection. The extended flag is the
caller's own, though — running one term with it off and on isolates exactly the
rows matched through a relation, a bit per term on collections no check covered —
so the exemption stops there.

A collection with no `searched_fields` at all is read the same way: silence is
not an empty footprint either.

This refuses requests that are served today: an agent whose collection installs
a `replace_search` block now answers 403 on an extended search there. Converting
that block to the field selection the previous commit added restores it, checked
per path. Shipped as a fix rather than a breaking change, matching how node
shipped the same policy.

Three specs pin the refusal — the extended half of a search served plain, a
collection that cannot answer at all, and a real `replace_search` block against
the field selection that is checked instead — and all three fail when the guard
is disabled.

Verified as CI runs it: root rubocop 840 files / no offenses;
`BUNDLE_GEMFILE=Gemfile-test rspec` on forest_admin_agent 1179 examples,
forest_admin_datasource_customizer 721, forest_admin_datasource_rpc 168,
forest_admin_datasource_toolkit 478 — 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…able field

Adversarial review of the two commits above found this, and it is the sharp one.

`search_condition_tree` handed `ConditionTreeFactory.union` an empty list
whenever no field was searchable. `union` answers nil for an empty list, and
`intersect` drops a nil branch — so the filter came back carrying neither a
condition nor a search, and the request answered *every* row for a term that
matched nothing. A narrowing customization that resolved to an empty set widened
the result to everything.

Reachable straight from the feature: `only_fields: []`, or `exclude_fields`
covering every searchable column. Also reachable without any customization, on a
collection whose columns are all unsearchable — two existing specs pinned that,
and both said so in their own titles while asserting the opposite:

  it 'adds a condition to not return record if it is the only one filter'
    expect(refined_filter).to have_attributes(condition_tree: nil)

`nil` is not "return no record", it is "no restriction". Those two specs now
assert `match_none`, which is what their names always claimed and what node
returns here. The enum-value-not-found case a few lines above already answered
`match_none`, so this also removes an inconsistency between two identical
situations.

Note this changes what a search returns on collections that have no searchable
column at all, with no `replace_search` involved.

Also from the same review:

- a symbol field name raised `NoMethodError: undefined method 'include?' for an
  instance of Symbol` from inside the toolkit, defeating the point of resolving
  strictly to get a clear error. Field paths are normalised to strings, so
  `only_fields: [:pan_last4]` resolves like the string does.
- `refine_filter` returns early instead of nesting, and the predicate it tests is
  now the `implements_search?` that `enumerable_search?` reuses, so the footprint
  and the tree cannot drift on a condition spelt twice.

Comments that restated the code are gone with it: the ones `implements_search?`
and `match_none` now say in code, and the public doc trimmed to what a caller of
`replace_search` has to know.

Verified as CI runs it: root rubocop 840 files / no offenses; Gemfile-test rspec
on forest_admin_datasource_customizer 724, forest_admin_agent 1179,
forest_admin_datasource_toolkit 478, forest_admin_datasource_rpc 168 —
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on, and stop 403ing a blank search

Both found by Macroscope on #382, both confirmed by probing rather than reading.

`only_fields: ['holder']` passed validation and then raised
`NoMethodError: undefined method 'column_type' for ManyToOneSchema`, from
`searched_fields` and from `refine_filter` alike. `get_field_schema` only
requires a to-one relation of the segments it *crosses*; it happily returns the
relation itself when it is the last one, so the assumption that resolution
guarantees a column was wrong. `resolved_field` now rejects a non-column leaf and
names the type it found, at customization time like every other bad name.

`collect_search_usages` refused `?search=&searchExtended=1`: a blank search is
not nil, so it reached the footprint check and 403'd on a collection whose
footprint is unknown — a request that runs no search at all. `refine_filter`
discards a blank search rather than running it, so there is nothing to
authorize; the guard now matches, whitespace included. node skips the empty
string here because `''` is falsy in JS, but not `'   '`, so this is slightly
tighter than node rather than a port of it.

Both guards are mutation-checked: each fails exactly its own spec when disabled.

Left as is: qlty flags `replace_search` for having four parameters. The three
keywords are the point — they are what makes a misspelt key raise, which a hash
or `**options` would swallow, and a spec pins that. Four is the count of a
deliberate signature, not of a smell.

Verified as CI runs it: root rubocop 840 files / no offenses; Gemfile-test rspec
on forest_admin_agent 1180, forest_admin_datasource_customizer 725,
forest_admin_datasource_toolkit 478 — 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h field selection

Asked whether the polymorphic case was validated, it was not: the claim that
resolution refuses a polymorphic path rested on the same reading of
`get_field_schema` that the bare-relation bug had just proved incomplete. Probed
rather than re-read, and the behaviour does hold — but nothing pinned it, and one
of the four cases only works because of the previous commit:

- a path crossing a polymorphic relation raises `Unexpected field type
  PolymorphicManyToOne`, in include and in exclude alike
- naming one bare raises `a PolymorphicManyToOne is not a column`. Before the
  previous commit this crashed with `NoMethodError` on `column_type`, exactly
  like the ManyToOne case that was reported
- an extended search keeps its targets out of both the footprint and the
  condition tree, so no footprint entry ever carries the several collections
  `leaf_collection_names` answers for a polymorphic leaf — which is what lets the
  permission check stay a single-collection question

Also fixes a trap in the specs added earlier: `caller` with no `let` in scope is
`Kernel#caller`, so those examples were passing a backtrace to `refine_filter`.
Unused on that path, so they passed while testing less than they read as doing.
The `let` is hoisted to the top-level describe and the two duplicates dropped.

Verified as CI runs it: root rubocop 840 files / no offenses; Gemfile-test rspec
on forest_admin_datasource_customizer 729, forest_admin_agent 1180 — 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`searched_fields` answers nil for three unrelated reasons, and only one is
a footprint this stack could have described: a `replace_search` block chose
the fields. Refusing the other two took extended search off every natively
searchable collection — Zendesk declares one per collection, RPC mirrors
whatever the remote agent declares — for a check the datasource never let
us make anyway. Gated on `permission_system?` as well: `can?` allows
everything without one, so the refusal was the single denial no grant could
lift.

`search_handler?` is delegated on `CollectionDecorator` the way
`searched_fields` is. Without it the predicate never reaches the search
decorator from the top of the stack and the refusal fires for nobody. The
new spec drives a booted `DatasourceCustomizer` rather than a decorator
built by hand, and it is the only one of the 42 that catches that
delegation going missing.

`searchExtended` read `false`, `'false'`, `'FALSE'`, `''` and `0` as
extended, and `||` discarded a real `false` in the select-all subset query
for whatever the query string carried. It only widened a search before; it
now gates a refusal.

An empty field list, or `only_fields` alongside another list, installed a
search matching nothing behind a bar the schema still advertises. A
selected path is held to the same `searchable_field?` bar the defaults
pass, at boot only: the request-time check rested on a schema mutation the
RPC refresh cannot produce, and turned drift into a 500 that leaked the
developer message to the client.

The unchecked native search on RPC-backed collections is pre-existing and
left as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthv
matthv force-pushed the feat/prd-1078-replace-search-field-selection branch from 4917da5 to 540ea0b Compare August 31, 2026 16:16
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.

@matthv

matthv commented Aug 31, 2026

Copy link
Copy Markdown
Member

One bug found, and it was in the addition this PR is built around. searched_fields answers nil for three unrelated reasons, and the refusal treated them as one: a replace_search block choosing its own fields, a child collection that searches natively, and a collection with no search decorator at all. Only the first is a footprint this stack could have described. The other two took extended search off every Zendesk collection and every RPC-backed one — and Zendesk never reads search_extended, so the refused request would have produced the same query as the served one.

Narrowing it exposed a second problem worth naming, because it is the shape that hides this class of bug: search_handler?, the predicate telling the two apart, is only defined on SearchCollectionDecorator, and CollectionDecorator delegates method by method with no method_missing. The guard is handed the top of the stack, so respond_to? answered false and the refusal fired for nobody. Every spec passed, because they all address a double or a decorator built by hand. It is delegated now, and one spec drives a booted DatasourceCustomizer — the only one of the 42 that catches that delegation going missing.

Also fixed: the refusal now sits behind permission_system? (can? allows everything without one, so it was the single denial no grant could lift); searchExtended read false, 'false', 'FALSE', '' and 0 as extended, and || discarded a real false in the select-all subset query; an empty field list or only_fields alongside another list installed a search matching nothing behind a live search bar.

One thing deliberately reverted from the review's advice: the searchable_field? bar on a selected path is boot-time only. The request-time check rested on a schema mutation the RPC refresh cannot produce — it rebuilds the decorator and replays the customization — and it turned schema drift into a 500 that returned the developer message to the browser.

rubocop 840/0, and the twelve suites green on seed 1 (agent 1200, customizer 737, toolkit 483, rpc 169).

`parse_search` returned whatever the request carried, and three places
strip it: the permission guard, `refine_filter`, and
`insignificant_search?`. A number reaching any of them raised
NoMethodError, so guarding one would have moved the crash rather than
removed it. It is coerced at the source now, and a value that cannot be
one term — an array, a hash, a boolean — is a request error instead of a
search for the string an Array prints as.

The spec pinning `parse_search` returning 1234 is titled "converts the
query search parameter as string", so the preservation was a bug its own
name contradicted. The assertion matches the title again.

Also extracts the `replace_search` validation, which qlty flagged for
complexity, and states the only_fields rule as `selection.keys !=
[:only_fields]` rather than enumerating the lists it cannot join.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
return unless only_fields && selection.keys != [:only_fields]

raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
'replace_search accepts only_fields on its own, not alongside include_fields or exclude_fields'

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 = 5): assert_search_replacement [qlty:function-complexity]

@christophebrun-forest christophebrun-forest left a comment

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.

Read the full diff and ran the sharp parts rather than only re-reading them: bundle exec rubocop clean over the 236 lib files, forest_admin_datasource_customizer 737 examples / 0 failures, forest_admin_agent 1203 / 0 — the numbers in the description hold. The probes quoted below were run on a detached worktree, one against origin/main, one against ca75407.

The core of the feature is good. Unifying the footprint and the condition tree behind searchable_fields makes the invariant structural rather than only tested, and the spec comparing searched_fields to condition_tree.projection — numeric term included — is the right test for exactly that. Strict resolution at boot beats node's silent drop, and the end-to-end example through a booted customizer stack is what stops the search_handler? delegation from disappearing without a failing spec.

Four comments are inline (1 on parse_search, 2 on assert_extended_search_checkable, 5 on the DSL doc block, 6 on resolved_field). Three have no line to anchor to:


3. The "Behavioural change to be aware of" paragraph is not what ships

ConditionTreeFactory.group compacts before it counts, so union([nil, nil]) already returns ConditionTreeBranch('Or', []) — which is precisely what default_replacer's .map produced on main whenever a term matched no column. Probed on origin/main (v1.41.0), collection with Number columns only, search "abc":

MAIN: searchable=true condition_tree=#<ConditionTreeBranch @aggregator="Or", @conditions=[]>

And an empty Or is not "match none" downstream. ActiveRecord's apply_condition_tree iterates zero conditions and hands the query back untouched — probed on ca75407:

SQL FOR match_none => SELECT "cars".* FROM "cars"

Snowflake does the same (return nil if fragments.empty?), Mongoid emits {"$or" => []} which the server rejects, and Hasura is the only one that reads it as false.

So b16d759d changes behaviour only where searchable_fields is itself empty, and on the default datasource that case is unobservable. Suggest rewording the paragraph to what the commit actually does: the decorator now answers match_none instead of nil, and the datasources still have to translate an empty branch (point 7).

To be explicit, since the finding could read as an argument against the commit: keep it. filter_map without the match_none line would be a genuine regression — the first case above would fall back to union([]) -> nil -> every row — and the commit also carries the String normalisation of paths and implements_search?. The two lines belong together.

4. Make the breaking change discoverable before the first 403

An agent installing a replace_search block now answers 403 on an extended search, shipped in a minor. As it stands the customer learns this from a user hitting the 403. A logger.log('Warn', ...) at boot, naming the collection and pointing at the include_fields: form, is a few lines and changes what kind of event this is for them.

7. Separate ticket, and it outranks this PR: translate an empty branch in the datasources

Or + [] -> false, And + [] -> true. ActiveRecord: @query.none; Snowflake: 1 = 0; Mongoid: do not emit $or: []. Hasura is already correct.

This is pre-existing and live on main today — the first probe above is an ordinary user typing a word into the search bar of a numeric-only collection and getting every row — and it reaches well past search: match_ids([]) returns the same match_none, so a bulk delete or a non-global smart action on an empty selection builds a query with no WHERE on ActiveRecord. Worth opening whatever happens to this PR; it is also what will make b16d759d's promise true.

Comment thread packages/forest_admin_agent/lib/forest_admin_agent/utils/query_string_parser.rb Outdated
Comment thread packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb Outdated
Both guards in `parse_search` tested truthiness, so `false` skipped the
type check and `Collection is not searchable` alike, and `&.` stops on
`nil` only — it became the term "false". Testing `nil` closes that, and
the body is now read by presence like the flag beside it: a boolean can
only arrive through the select-all query, where `||` discarded it before
either guard saw it.

`describes_own_search?` is local while `permission_system?` fetches
`/liana/v4/permissions/environment`. In the previous order every
extended search on a collection with an unknown footprint reached that
fetch — every Zendesk collection, every RPC-backed one, and every
collection carrying no search decorator — where `read_permissions` only
paid for it when a relation path existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`exclude_fields: ['holder']` raised, so "extended search, but not
through this relation" meant listing the target's columns and
remembering to revisit that list every time it gained one. An excluded
name never carried `include_fields` constraint anyway, only having to
exist to be dropped, so naming a relation now drops every path through
it.

A block replacer logs a Warn at boot naming the collection and the
`include_fields:` form. The refusal ships in a minor, and without this
the first sign of it is a user hitting the 403.

The DSL block was missing three surprises, each in the same direction:
an included relation path is read on a plain search too, `only_fields`
makes `extended` inert, and names resolve below this layer while
`rename_field` sits above it, so a field renamed to `title` is still
named `name` here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthv

matthv commented Sep 2, 2026

Copy link
Copy Markdown
Member

The three points with no line to anchor to.

3 — reworded in the description. The conclusion holds: that paragraph described a bug that was not there, and the commit should stay. One correction to the reasoning, because it changes what the paragraph should now say: an empty Or is read as match-none before any translator sees it. EmptyCollectionDecorator sits below the search layer (decorators_stack.rb:60 vs :73) and intercepts list / aggregate / update / delete, and or_return_empty_set returns true on conditions.empty?. So main already answered 0 rows for a term matching no column — not through the translators, through that decorator. Behaviour changes only where searchable_fields is itself empty, where union([]) answered nil and intersect dropped it.

4 — done in b692b1f. A block replacer logs a Warn at boot naming the collection and pointing at the include_fields: form, so the refusal is not discovered through a user's 403. Two specs: one that it fires for a block, one that a field selection says nothing.

7 — worth the ticket, but the blast radius needs correcting before it is prioritised. The translator half is real and reproduces as described. The consequence does not:

delete.rb:80 calls context.collection.delete, i.e. the top of the stack, so the call descends through EmptyCollectionDecorator#delete, which is super unless return_empty_set(filter.condition_tree). For an empty selection the tree is Or([])match_fields returns match_none on empty values, confirmed — so super is never reached and no query is built. With a scope it is And([leaf, Or([])]), and and_return_empty_set sees exactly one empty-set branch, so it short-circuits too. A bulk delete on an empty selection therefore deletes nothing today rather than emitting a WHERE-less statement.

So the ticket is defence in depth — plus Mongoid, which would raise rather than over-match — not a live data-loss path. Filed as the latter it will read as an incident.

One genuine gap in that decorator while it is in scope: and_return_empty_set tests conditions.one?, exactly one, so And([Or([]), Or([])]) is not caught by that branch and falls through to the mutual-exclusion scan. Narrow, but it belongs with the rest.

matthv and others added 3 commits September 2, 2026 11:31
The boot warning added for a block replacer reached
`ForestAdminAgent::Facades::Container.logger`, which is nil in the RPC
agent: it subclasses `AgentFactory`, and Singleton gives the subclass its
own instance, so the base container is never built there. An RPC agent
with a `replace_search` block therefore stopped booting, on a
NoMethodError naming nothing about search. The maintainers already knew
the facade returns nil outside the plain agent — `sinatra_extension`
writes `logger&.log`. A warning must not become a boot failure.

Its wording was wrong too, in both the log line and the public
docstring: the refusal is also gated on `permission_system?`, so an
agent with permissions disabled printed a warning about a 403 that never
fires.

`exclude_fields` accepted names it could never match. Only own columns
and depth-1 to-one paths are ever searched, so `['lines']`, a depth-2
path, a polymorphic relation named bare, or a column no term can match
were taken and silently ignored — the configuration lied. They are
refused now, and a path both selected and excluded raises rather than
letting one side win silently: exclude-then-merge drops a column the
developer named, merge-then-exclude makes an excluded one searchable,
and neither is a contract worth documenting.

Two specs went with it. One listed `holder_id` among "every searchable
field" though the search never read it. The other asserted the
unmodified default footprint, so it passed whether or not the exclusion
did anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Testing the subset query by key presence fixed a `false` losing to the
query string, but it also let an explicit null there win — dropping the
term the URL carried, which widens a result set rather than narrowing
it. Only a present, non-nil body value masks the query string now.

The precedence block was duplicated between the search and the flag,
each with its own comment saying the same thing, one of them by
cross-reference. It is one helper, so the rule is stated once, and the
digs are guarded the way `parse_search_extended`'s neighbour 220 lines
above already guards its own: `?data[attributes][]=1` walked `Hash#dig`
into an Array and answered 500 instead of 400.

Drops the note claiming every consumer strips the term. The three sites
named do strip, but only to test significance — `build_condition` hands
the unstripped string to the condition leaf, so a padded term is what
the datasource compares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`count` and `csv` asserted only that they passed the extended flag, not
its value. Replacing the parsed flag with a literal `false` in either
route left the suite green, so the refusal this PR exists to add could
be switched off on the bulk-export route unnoticed. `list` already had
the value pinned; the collector was shared, the examples were not.

`CollectionDecorator#search_handler?` had no spec in the package that
owns it. Stubbing its body dead passed the toolkit's own suite, and only
one example in another package caught it — so the normal workflow of
editing the toolkit and running the toolkit ships the fail-open that
already happened once.

The blank-search example looped two spellings inside one `it`, so a
failure never named which one, and the exclusion fixture had no
searchable column whose name the relation merely prefixes: dropping the
colon from the prefix match passed everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthv

matthv commented Sep 2, 2026

Copy link
Copy Markdown
Member

All six actionable points are on the branch, and one of them cost more than it looked.

Point 4 was the expensive one — the boot warning I added for it crashed the RPC agent. ForestAdminAgent::Facades::Container.logger is nil there: ForestAdminRpcAgent::Agent subclasses AgentFactory, and Singleton gives the subclass its own instance, so the base container is never built. An RPC agent carrying a replace_search block stopped booting, on a NoMethodError naming nothing about search — in Rails the error.handle(ForestException) does not catch it, in Sinatra it sits in app.before so every request 500s. Nil-guarded and non-fatal in c4f3e79. The tell was two files over: sinatra_extension.rb:23 already writes Container.logger&.log.

Your wording objection applied to the warning itself, too: it said "will be refused" while the refusal is also gated on permission_system?. Both the log line and the public docstring now say "where permissions are enabled".

Point 6 went further than you suggested, so push back if you disagree. A bare to-one relation is accepted and drops every path through it, as you proposed. But relaxing the check also started accepting names that can never match anything — ['lines'] on a to-many, ['holder:org'], a depth-2 path, a polymorphic relation named bare, or a column no term can match. Those were taken silently and excluded nothing, which is the same lie in the other direction, so they now raise. And a path both selected and excluded raises rather than letting one side win: exclude-then-merge drops a column the developer named, merge-then-exclude makes an excluded one searchable, and neither is a contract worth documenting.

Two of your own points landed in the specs rather than the code. The list in "every searchable field is excluded" named holder_id, which the search never read; and "still accepts excluding that column" asserted the unmodified default footprint, so it passed whether the exclusion did anything or not.

Points 1, 2, 5 are in 7f7bf0b and b692b1f, answered in their threads. Point 1 needed one thing beyond testing nil: a boolean can only arrive through the select-all body, where || discarded it before either guard saw it, so the body is read by presence now — and an explicit null there is treated as absent rather than as "no search", since dropping a term widens a result set (84e1f54).

Point 7 stays yours. One thing to fold in while you are there: EmptyCollectionDecorator#and_return_empty_set tests conditions.one?, exactly one, so And([Or([]), Or([])]) escapes that branch and falls through to the mutual-exclusion scan.

rubocop 840/0; 12 packages, 4254 examples, 0 failures on seed 1.

@christophebrun-forest christophebrun-forest left a comment

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.

Re-reviewed at d3378a3f. Verified as CI runs it: bundle exec rubocop 840 files / no offenses, forest_admin_datasource_customizer 743, forest_admin_agent 1214, forest_admin_datasource_toolkit 485 — 0 failures.

I was wrong about point 3, and the correction matters

I claimed match_none was not honoured end-to-end, and that an ordinary search matching no column already returned every row on main. Both are false, and your EmptyCollectionDecorator note is right. It sits below the search layer and or_return_empty_set starts with conditions.empty?, so the tree is intercepted before any translator sees it. Probed through a booted customizer stack, numeric-only collection, search "abc", child list stubbed to return two records:

PROBE list result => []

The child's list is never consulted. So b16d759d does exactly what its message says, in the only case it changes (searchable_fields itself empty, where union([]) answered nil and nothing intercepted a nil), and it lands. My probe on the AR query builder was real but measured a layer nothing reaches through the stack — I read Or [] arriving at the translator, which it never does.

That also demotes point 7: the bulk-delete-with-empty-selection scenario I described goes through EmptyCollectionDecorator#delete too, so it is not the live bug I called it. A latent trap in the translators, worth the separate ticket you have, and it does not outrank this PR. Sorry for the misdirection.

What the round turned up

Three comments inline. The first is a real one, one method away from the fix you just made.

Also worth saying: the holder_note fixture is the right instinct — a prefix match with no sibling column to distinguish it is a test that cannot fail — and pinning count/csv on the flag's value rather than its presence caught a genuine hole, since the refusal could have been switched off on the export route with the suite green. Same for moving the search_handler? example into the package that owns it.

# so the base facade's container is never built there — a customization must not become a boot
# failure over a warning.
def warn_extended_search_refused
logger = ForestAdminAgent::Facades::Container.logger

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.

This guard is right, and the reasoning in c4f3e790 is right — but the same file still calls the same facade unguarded at :272, inside get_fields:

ForestAdminAgent::Facades::Container.logger.log(
  'Debug',
  "We're not searching through #{self.name}.#{name} because it's a polymorphic relation. ..."
)

So the crash you just fixed for the boot path is still live on the request path. An RPC agent doing an extended search on a collection carrying a polymorphic relation reaches it. Probed at d3378a3f, logger stubbed to nil:

PROBE RAISED: NoMethodError: undefined method 'log' for nil

Pre-existing rather than introduced here, but the file now has one guarded and one unguarded call to the same nil-able facade, and this commit's own message is the argument for guarding it. logger&.log matches what context_variables.rb:22, csv_generator_stream.rb:54 and the whole of datasource_rpc already do.

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.

Taken in fc2ffa4 rather than left to you — your Slack said you would do it before merge, but it is the same file I was editing for the exclusion point, so doing it in parallel would have put us on the same lines.

You are right that this is the live half: the boot path was what I guarded, and get_fields is the request path. The three calls in this file now all handle nil. A spec pins it with Container.logger stubbed to nil and an extended search reaching the polymorphic relation, and removing the &. fails it.

Two more in the same package are untouched, being outside this PR: publication_collection_decorator.rb:68 and compute_collection_decorator.rb:20. Same facade, same failure mode on an RPC agent.

raise ForestException, "Cannot exclude '#{path}' from the search: the search does not read it"
end

def excludable?(path, schema)

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.

Requiring searchable_field? here means an exclusion can turn into a boot failure, and it is the conservative configuration that breaks.

exclude_fields: ['ssn', 'internal_token'], written to keep PII out of the search bar, boots today. Let a schema change make one of those columns unsearchable — a type change to Json, an operator the datasource stops declaring — and the agent stops booting on the search does not read it, even though the developer's intent ("never search this") is now satisfied more completely than before. Nothing about that config became wrong; a column it names became less searchable.

The distinction I would draw is between the two failures you are currently treating alike:

  • a name that resolves to nothing — a typo — should raise, as it does now, and for include_fields the same is true of an unsearchable column, because there the intent genuinely fails;
  • a name that resolves to a real field the search happens not to read is a no-op that still states intent, and stays correct as the schema moves. Accepting it, with a Debug line if the silence is what bothered you, keeps the failure mode proportionate.

Your argument in c4f3e790 — "excluding it silently changes nothing, the configuration lied" — holds for the moment it is written. It stops holding the moment the schema moves under a config that was defensive on purpose.

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.

You are right, and I changed it even though you said it was fine as-is — the argument stands on its own.

The failure mode was disproportionate and it selected for the wrong configuration: exclude_fields: ['ssn'] boots today, a type change makes that column unsearchable, and the agent stops booting over a config whose intent just became more true. Nothing about it was wrong.

Your distinction is the one implemented in fc2ffa4: a name resolving to nothing is a typo and still raises, out of get_field_schema. A name resolving to a real field the search happens not to read is accepted and reported at Debug, so the silence that prompted the guard is gone without the boot failure. A path both selected and excluded keeps raising — a contradiction rather than a no-op, and no precedence between them is worth documenting.

nil
end

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

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.

Small one, on the same principle this helper was written for: '' in the subset body still wins over the query string.

PROBE body '' => ""

An explicit null no longer discards the term the URL carried, because dropping a search widens the result set — but an explicit '' does exactly that and is honoured. Reading '' as "explicit empty search" and null as "absent" is defensible; I just could not tell from the code whether it was decided or fell out of nil?. Worth a word either way, and it is academic today since no route reaches this branch — nothing calls parse_search with a body, get_record_selection builds its own filter and never applies the search its comment claims to.

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.

It fell out of nil?, it was not decided — so you found a real inconsistency rather than a documented choice. Fixed in fc2ffa4: '' is read as absent too.

The reason for the null case applies unchanged: discarding the term the URL carried widens the result set, and honouring an empty body value did exactly that. One rule now, stated once in the helper.

Agreed it is academic today — nothing calls parse_search with a body, and get_record_selection builds its own filter without applying the search its comment claims.

@christophebrun-forest christophebrun-forest left a comment

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.

  • 1 important before merge: logging not retained
  • 1 to validate: excludable, and okay to send as is
  • 1 optional: the ''

The nil-logger crash was only half fixed. `get_fields` calls the same
base facade unguarded, and that is the request path: an RPC agent
running an extended search on a collection carrying a polymorphic
relation reached it and answered 500. The three calls in this file now
all handle nil.

Requiring a selected column's searchability of an excluded one turned
schema drift into a boot failure, and it was the defensive configuration
that broke: `exclude_fields: ['ssn']` written to keep PII out of the
search bar stopped the agent booting the day that column turned
unsearchable — the one change that made the intent more true, not less.
A name resolving to nothing is still a typo and still raises; a name
resolving to a real field the search happens not to read is reported at
Debug instead. A path both selected and excluded keeps raising, being a
contradiction rather than a no-op.

An explicit empty string in the select-all body no longer wins over the
query string either. A null there was already read as absent because
discarding the term the URL carried widens the result set, and `''` did
exactly that while being honoured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@christophebrun-forest christophebrun-forest left a comment

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.

LGTM

@matthv
matthv merged commit add4b1b into main Sep 2, 2026
33 checks passed
@matthv
matthv deleted the feat/prd-1078-replace-search-field-selection branch September 2, 2026 15:12
forest-bot added a commit that referenced this pull request Sep 2, 2026
# [1.42.0](v1.41.0...v1.42.0) (2026-09-02)

### Features

* **datasource-customizer:** let replace_search take a field selection so a narrowed search is permission-checked ([#382](#382)) ([add4b1b](add4b1b))
@forest-bot

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.42.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants