fix(datasource-customizer): let a narrowed search be permission-checked instead of refused - #1852
Conversation
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (1)
🛟 Help
|
3919c5e to
d658309
Compare
…ed instead of refused `getSearchedFields` answered `null` for any `replaceSearch`, so the agent could not state what such a search reads and refused every extended search on the collection (#1840). The refusal is right for a handler, which picks its own fields. It is not right for a handler that only narrows the default search: the fields are `includeFields` / `excludeFields` / `onlyFields`, and the footprint is exactly computable. `replaceSearch` now also accepts that field selection directly, as an object rather than a function. `getSearchedFields` answers the real footprint for it, so the authorization layer checks each path against the caller's read permissions and refuses the denied ones by name — instead of refusing the whole search. The two derivations are unified behind `getSearchableFields`, which both the condition tree and the footprint now come from. They were computed separately, which is why the footprint could only be stated for the plain default search; keeping one source of truth is what makes the check trustworthy, and a test pins that every path the search reads is covered by the footprint it reports. Note that a field selection is stricter than the equivalent handler, not merely more permissive: an included relation path is reported on a plain search too, so it is checked where a handler was exempted. The exemption only ever existed because the footprint was unknown. A function definition is unchanged: still exempt on a plain search, still refused on an extended one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d658309 to
26aa52e
Compare
|
Ran a multi-angle review (correctness, tests, comments, type design) on this PR. The core invariant — A few things worth addressing before merge:
Nothing here contradicts the PR's stated goal or the security invariant it's meant to restore — these are refinements on top of a sound refactor. |
… is matched
Review follow-ups on the declarative `replaceSearch`.
`excludeFields` compared raw against the resolved field name while the included
paths go through `lenientGetSchema`, so `excludeFields: ['panLast4']` silently
failed to drop a `pan_last4` column that `includeFields: ['panLast4']` resolves.
Both sides now resolve the same way; an unresolvable name is kept as written,
since it excludes nothing either way. Two tests pin it, on a column and on a
relation path, and both fail without the fix.
`SearchDefinition` keeps pointing at the handler alone. It shipped as a callable
type, and widening it to a union stopped compiling for anyone who called what
they had typed with it (TS2349). The union `replaceSearch` accepts is
`SearchReplaceDefinition`; `SearchHandlerDefinition` names the handler form
descriptively and `SearchDefinition` stays an alias of it.
The permission change itself is now covered end to end rather than through a
stubbed `getSearchedFields`: a real `replaceSearch({ includeFields })` driven
through `DataSourceCustomizer` and the list route, for the extended search that
is now refused by name instead of for want of a footprint, the plain search a
handler used to be served, the extended search a field selection buys back, and
the handler form still refused.
Also: the footprint-covers-what-is-read invariant now has its `excludeFields`
case, the two existing ones share the collection they duplicated, the
`getSearchedFieldPaths` docstring no longer claims to resolve paths the way the
decorator does (it is unused by it, and nothing keeps them in step), and
`replaceSearch`'s doc states that a field selection replaces a natively
searchable datasource's search rather than narrowing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the follow-up commit — it addresses the One item from the original review is still open: Unresolvable paths are silently dropped ( Not blocking if you'd rather track it separately — flagging so it doesn't fall through the cracks. |
|
Thanks — five of the seven are addressed in 2bb8ef8. Two I left alone on purpose, reasons below. Fixed1. 4. 5. No end-to-end test of the permission change. Added — four tests driving a real 6. 7. Missing Not changed2. Unresolvable paths silently dropped. Also pre-existing on 3. A field selection discards a datasource's native search. The mechanism is real — it replaces the native search with the generic per-column one rather than narrowing it — but the stated consequence isn't. I probed it: Verified
|
…requires The `SearchReplaceDefinition` type import landed after the toolkit one and with a blank line inside the group, which `import/order` rejects. Caught by CI, not locally: eslint ran before that import was edited in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## @forestadmin/datasource-customizer [1.71.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/datasource-customizer@1.71.2...@forestadmin/datasource-customizer@1.71.3) (2026-08-27) ### Bug Fixes * **datasource-customizer:** let a narrowed search be permission-checked instead of refused ([#1852](#1852)) ([bb81798](bb81798)), closes [#1840](#1840)
## @forestadmin/agent [1.98.3](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.98.2...@forestadmin/agent@1.98.3) (2026-08-27) ### Bug Fixes * **datasource-customizer:** let a narrowed search be permission-checked instead of refused ([#1852](#1852)) ([bb81798](bb81798)), closes [#1840](#1840) ### Dependencies * **@forestadmin/datasource-customizer:** upgraded to 1.71.3

Why
#1840 refuses an extended search whenever
getSearchedFieldscannot state what the search reads, and the search decorator answersnullfor anyreplaceSearch:That refusal is correct for a handler that picks its own fields. It is too broad for the common case — a handler that only narrows the default search:
Here the fields are a declarative list and the footprint is exactly computable, yet the collection loses extended search entirely. Three collections in the Forest SaaS back-end hit this on the 1.97.3 bump (
trial,subscription,invoice) — one caught by a test, two silently.subscriptionandinvoicepass two-level paths (billing:project:name) that the plain default search cannot express, so dropping the handler is not a workaround.The refusal exists because the footprint could not be stated, not because it could not be known.
generateSearchFilteralready computes it:searchableFieldsis the answer.What
replaceSearchnow takes either form:extendedis deliberately absent from the object type (Omit<SearchOptions, 'extended'>): the caller owns that flag, so it is forwarded from the request rather than pinned by the customization. A test pins that forwarding, andtscrejects both{ extended: true }and a misspelt key.Migrating a handler is not a no-op — it is stricter
Worth stating plainly, because it is the one thing that can surprise: a field selection is more restrictive than the handler it replaces, not less.
An included relation path is reported on a plain search too, not only an extended one. So it gets permission-checked where the handler was exempted — and that exemption only ever existed because the footprint was unknown. Concretely,
replaceSearch({ includeFields: ['project:name'] })starts refusing a plain search for a role that cannot readprojects, where the handler form served it.That is the correct posture, and it is the same permission sweep #1840 already asks for. But it means converting a handler is a behavioural change to plan, not a mechanical rewrite.
The part worth reviewing
Both derivations are unified behind
getSearchableFields, andgenerateSearchFilter/getSearchedFieldsnow come from it. They were computed separately —getSearchedFieldsreimplemented a subset viagetSearchedFieldPaths+getFields— which is exactly why the footprint could only be stated for the plain default search, and would have drifted from what the query reads.That invariant is what makes the permission check trustworthy, so it is pinned rather than assumed, in two tests:
it covers every path the search actually reads— every path in the generated condition tree is in the reported footprint, asserting the traversed path by name so it cannot pass vacuously on a search that never leaves the root collection.it covers what a dot-syntax term reads once onlyFields replaced the set— the sharp case:onlyFieldsdrops thefield:termsyntax from the searchable set, and the walker then searches the replaced set with the term reassembled. A separately-derived footprint would have missed which columns that actually reads.The invariant is also structural, not just tested:
ConditionTreeQueryWalkeronly ever builds leaves from the field list it is handed, which is that same map.Two incidental effects of the unification, both improvements: the footprint is deduplicated (it is a
Map, and the specified-field and default-field lists could overlap), and specified fields resolve through the samelenientGetSchema(this, …)call the query builder uses instead of a parallel one. One consequence to be aware of: the footprint's order changes (defaults first), so when several paths are denied, the error names a different one than before. No test depended on it.Not in scope
getSearchedFieldsstill claims a footprint it cannot guarantee when the child collection searches natively.refineFilterhands that search straight down (!this.childCollection.schema.searchable), so the child reads whatever it likes, whilegetSearchedFieldsreports an enumeration of the child's columns.agent-rubyguards this case explicitly (enumerable_search?is@replacer.nil? && !@child_collection.schema[:searchable]) and node does not. Narrow today — no shipped datasource callsenableSearch(), onlypackages/_example— but it is an unverified claim, and closing it would refuse extended search on those collections, which is a separate decision from this PR.getSearchedFieldPathsis now unused insrc. It is exported from the package index as of 1.71.x, so it is left in place rather than removed in a patch.refineFilter, both out of proportion here.Tests
packages/datasource-customizer/test/decorators/search/collections.test.ts: 8 added — the footprint answered rather than refused,excludeFields/includeFields/onlyFieldsreflected in it, the leaf collection of a relation path named on a plain search as well as an extended one, the extended flag forwarded rather than pinned, and the two footprint-covers-what-is-read invariants.Verified locally:
datasource-customizer875 passed / 63 suites;packages/agentsecurity + authorization + utils 508 passed / 25 suites (the #1840 suite included, unchanged); tsc clean; eslint clean.🤖 Generated with Claude Code
Note
Let field-selection
replaceSearchbe permission-checked instead of refusedCollectionCustomizer.replaceSearchto accept aSearchReplaceDefinitionunion: either a handler function or a field-selection object (includeFields,excludeFields,onlyFields).SearchCollectionDecorator.refineFiltergenerates a narrowed search filter viagenerateSearchFilterand forwards the request'sextendedflag, so the search goes through normal permission checks rather than being refused.getSearchedFieldsnow returns a concrete footprint for field-selection and default-search modes (instead ofnull); it returnsnullonly when a handler function is installed.getSearchableFieldsunifies resolution of searchable fields from default fields, query-specified fields, and the selection's sets;excludeFields/includeFieldsare resolved case-insensitively and leniently against actual schema names, ignoring unresolved names.getSearchedFieldsnow returns a concrete footprint in field-selection mode where it previously returnednull— any consumer relying onnullto detect a customized search will see different values;excludeFieldswith names not matching any schema field are silently ignored instead of erroring.Macroscope summarized cf0af5a.