Skip to content

fix(agent): serve only the columns of collections the caller may read - #365

Open
PMerlet wants to merge 4 commits into
mainfrom
fix/prd-900-agent-read-permission-on-projected-collections
Open

fix(agent): serve only the columns of collections the caller may read#365
PMerlet wants to merge 4 commits into
mainfrom
fix/prd-900-agent-read-permission-on-projected-collections

Conversation

@PMerlet

@PMerlet PMerlet commented Aug 21, 2026

Copy link
Copy Markdown
Member

Why

A read is permission-checked on the root collection only. Every column a projection, a filter or a sort reaches through a relation path is served with no check on the collection it comes from.

A role with read on cards and nothing at all on holders gets this in full:

GET /forest/cards
Forest-Projection: id,holder:national_id,holder:date_of_birth

The header is not even required — GET /forest/cards with no fields[] returns the same columns, because ProjectionFactory.all expands every column of every to-one relation.

The filter is the sharper half. It never returns the column, yet it answers one guess per request:

GET /forest/cards?filters={"field":"holder:national_id","operator":"starts_with","value":"1850"}

One row back or zero rows back is one digit. Ten iterations per character recovers a national id in full, from a collection with zero granted permissions, without the value ever appearing in a response.

fixes PRD-900

The rule

Check the collection each path ends on. Collections crossed on the way confer and require nothing — reaching one through a relation is a join, not a read — so account:organization:name needs read on organizations alone, and a ManyToMany through-collection stays out of it since it contributes no returned column.

What a denial does depends on who asked for the field:

Named by the caller (fields[], Forest-Projection) Refused, listing every offending path in one message so a client drops them all and retries once
Never asked for (the ProjectionFactory.all default) Dropped from the projection — refusing would turn an ordinary listing into a 403

Filters, sorts, extended searches and a chart's group-by or aggregated field are always refused. None has a prunable equivalent: dropping a condition widens the result set, dropping a sort clause silently reorders it, and a grouped-by key is chart output.

The check reads the caller's own query only. Scopes and segments are injected by the agent and may legitimately reference a collection the caller cannot read — a test locks that down.

Polymorphism changes the rule, not just the code

Unlike agent-nodejs, whose toolkit has no polymorphic relation type, this one has PolymorphicManyToOne, PolymorphicOneToOne and PolymorphicOneToMany. A PolymorphicManyToOneSchema carries foreign_collectionsplural — and a foreign_key_type_field as its discriminant.

So the leaf collection is no longer unique. holder:* resolves to persons or companies depending on the row, and the path carries no discriminant. leaf_collection_names therefore returns a list, and the rule becomes: every possible target must be readable, one denied is enough to deny the path. Same semantics as the front's .every over referenceModels.

Two details the validator imposes, and which make this reachable rather than theoretical: a PolymorphicManyToOne can only be projected as <relation>:* — any sub-field is rejected upstream — but ProjectionFactory.all includes that :*. The default expansion is what exposes a polymorphic relation, and it is now pruned when a target is denied.

PolymorphicOneToOne and PolymorphicOneToMany carry a single foreign_collection and go through the ordinary branch.

One resolver, in the toolkit

agent-nodejs duplicates this resolution — FieldPathUtils in the agent, getLeafCollectionName in the customizer — and it was the customizer copy that failed open, which a review caught late. Here Utils::FieldPath lives in the toolkit, shared by the agent and the search decorator, so the two halves of the check cannot disagree by construction.

Its default is fail-closed: a prefix naming no relation raises rather than falling back to the collection being resolved. The caller pins that collection to readable, so a fallback would turn "this path does not resolve" into "this path is allowed".

Search: asked of the layer, never derived from the schema

The route does not enumerate searchable fields. It asks the collection what a given search string and extended flag will actually reach, and the search decorator answers against child_collection — a field hidden by publication or renaming above it is still searched.

Three answers, and the third is what makes it safe. A list of paths gets checked; an empty list has nothing to check; nil means the layer cannot say and must never be read as "reaches nothing". Ruby has one more nil case than node: besides a customer replace_search, a natively searchable datasource (RPC, Zendesk) receives the search by delegation, so no enumeration made here would be true.

Deliberate differences from agent-nodejs

  • count_related needs no guard. It builds its filter from the scope alone and reads nothing from the caller, unlike its node counterpart.
  • store does need one. Ruby re-reads the created record with the full expansion and serializes it, where node's create serializes only caller-supplied primary keys. add without read on a related collection disclosed it.
  • One permission fetch per request, at most. can? refetches the whole environment on every denial, unconditionally — there is no instantCacheRefresh gate here. Since this change makes denial the steady state rather than the exception, read_permissions does one cached pass and forces at most one refetch for the whole request.

Routes covered

list, show, csv, count, list_related, csv_related, the chart routes, and the writes that serialize a record back with a projection of ours: update, update_field and store. Those three are redacted, never refused — a write must not 403 because the row it just wrote carries a relation the caller cannot read.

A Count leaderboard names no path back to the collection it counts, so browse is asserted on it directly, on the relation's foreign collection rather than the through-collection a ManyToMany aggregates.

Residual cost

Roles that display a related label today without read on the target lose it until an admin grants it. One permission sweep per project, visible and diagnosable rather than silent.

Dashboard leaderboards that count a relation break for existing roles, and no front change absorbs that path — chart requests are not pruned client-side. Same sweep, on browse rather than read.

Basic segments and saved views need auditing. The front prunes projections but not filters or sorts, so a segment whose condition targets a belongsTo sub-field of a denied collection makes its whole list view 403 — and a saved view sorted on such a column fails on load with no user action at all. See PRD-1014 for the front-side error surfacing.

Any client requesting relation fields its role may not read starts getting a 403 where it got a 200. That is the fix working: only integrations running under a role that should never have had the data are affected, and the error names the field and the collection so they can be fixed rather than guessed at.

Deliberately out of scope

Both are open in agent-nodejs too, and porting route-for-route would reproduce them:

  • Action routes — PRD-1015. The record selection takes the caller's filters and search unchecked. Guarding it is a breaking change with the same profile as the rest of this rollout, so it needs its own sequencing decision.
  • Delete and dissociate-delete routes — PRD-1012. Same unchecked filters, plus search.

Two linkage disclosures remain, consistent with the leaf rule chosen deliberately: with_pks re-adds a key per surviving relation, and the polymorphic linkage columns (holder_type, holder_id) stay — but those are columns of cards, readable independently of this change.

Tests

Toolkit 478, customizer 697, agent 1111 examples, rubocop clean on all three.

The new suites drive a real Permissions service rather than a double, so the guards themselves are under test and not the stubs the route specs install — including the four polymorphic cases, the fail-closed resolver, and both sides of the search guard.

One trap worth knowing before touching these specs: a missing stub on the permissions double makes the suite hang rather than fail. RSpec renders its unexpected-message error by inspecting the arguments, and a collection reaches its datasource, which reaches every collection, so the inspect never finishes. The shared context documents it.

Note

Redact unreadable fields from resource, CSV, and chart routes

  • Adds read permission enforcement to permissions.rb: computes readable collections (read_permissions), redacts projections (redact_projection), and asserts readability of fields used in filters, sorts, and search (assert_can_read_query_fields).
  • List, show, CSV, and related routes now drop unreadable fields from default field expansion; if a client explicitly names unreadable fields, the request returns 403. Headers in CSV exports are trimmed via CsvGenerator.filter_header to match the kept columns.
  • Write routes (Store, Update, UpdateField) use the new redacted_full_projection helper so created/updated records are returned with only readable fields.
  • Chart routes (charts.rb) assert readability of filter and aggregation fields; count aggregations now require browse permission on the target foreign collection.
  • Behavioral Change: requests that previously returned all fields now omit unreadable fields or 403 when explicitly requested; filters, sorts, searches, and chart aggregations referencing unreadable collections now 403.

Macroscope summarized 7873281.

A read was permission-checked on the root collection only, so every
column a projection, filter or sort reached through a relation was served
with no check on the collection it came from — and a starts_with filter
answered one guess per request without returning a column at all.

The collection a path ends on is now checked. A field the caller named is
refused with every offending path in one message; the default expansion is
dropped from the projection instead, since refusing there would turn an
ordinary listing into a 403.

A polymorphic relation resolves to several collections and carries no
discriminant, so every target must be readable: one denied is enough to
deny the path. The resolver lives in the toolkit so the search layer and
the routes cannot disagree about what a path reaches.

A Count leaderboard names no path back to the collection it counts, so
browse is asserted on it directly.

fixes PRD-900
read_permissions returned only the root collection, so every related name
was missing from the map and read as denied. can? allows everything when
no permission system is configured, and this side of the check has to
agree: without the fix, any projection through a relation was redacted and
any named field 403ed on those deployments.

Three more routes serialize a record back with a projection of ours:
store, and both sites in update_field. They are redacted like update,
through one helper the four of them now share.

The search guard no longer parses a search on a collection that has none —
parse_search raises there, which turned a parameter the chart routes
ignore into a 400.

Pins the toolkit path resolver and the search layer's answer, neither of
which had a test.
Eleven blocks restated a signature, a method name or a doc sitting on the
declaration they were calling. The rationale that cannot be read off the
code stays.
@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

PRD-900

@qltysh

qltysh Bot commented Aug 21, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure High total complexity (count = 62) 1

Comment thread packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/count.rb Outdated
extended = Utils::QueryStringParser.parse_search_extended(args)
searched = collection.searched_fields(search, extended)

searched&.each do |field|

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Critical services/permissions.rb:302

When searched_fields returns nil, the search is accepted without any related-collection permission checks, allowing searches against unreadable fields to leak information through result presence. nil denotes unknown fields for custom replacers and native child searches, but searched&.each treats it as no fields; fail closed by raising ForbiddenError when the footprint is unknown.

-        searched&.each do |field|
+        raise ForbiddenError, "Unable to determine searchable fields for permission check." if searched.nil?
+
+        searched.each do |field|
Also found in 1 other location(s)

packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb:65

searched_fields returns nil when @replacer is installed or the child collection searches natively. The permission guard consumes this with searched&amp;.each, so nil adds no usages and the search is allowed without checking any target collection. A custom replacement or native search that reaches a field on an unreadable related collection therefore retains the search-based data inference vulnerability this change is intended to close; an unknown field set must fail closed rather than be represented as an empty check.

🚀 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 302:

When `searched_fields` returns `nil`, the search is accepted without any related-collection permission checks, allowing searches against unreadable fields to leak information through result presence. `nil` denotes unknown fields for custom replacers and native child searches, but `searched&.each` treats it as no fields; fail closed by raising `ForbiddenError` when the footprint is unknown.

Also found in 1 other location(s):
- packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/search/search_collection_decorator.rb:65 -- `searched_fields` returns `nil` when `@replacer` is installed or the child collection searches natively. The permission guard consumes this with `searched&.each`, so `nil` adds no usages and the search is allowed without checking any target collection. A custom replacement or native search that reaches a field on an unreadable related collection therefore retains the search-based data inference vulnerability this change is intended to close; an unknown field set must fail closed rather than be represented as an empty check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two causes of nil are collapsed here, and they do not have the same status.

replace_search is a decided exemption, not an oversight. PRD-900 records it: the customer wrote the handler, and the caller controls the search string, not the fields it maps to — unlike filters={"field":"holder:national_id"}, they cannot aim at a column of their choosing. Authorizing the refined condition tree instead, which is the shape your fix implies, would check it after the scope has been intersected in and 403 a user out of their own collection whenever a legitimate scope references a related column. A test guards exactly that, and the same finding was answered on the node PR.

The native-search case is genuinely new here, and you are right that PRD-900 never covered it. Ruby's refine_filter only implements the search itself when the child collection is not natively searchable; otherwise the string goes down to the datasource. So searched_fields also answers nil for anything calling enable_searchdatasource_rpc/collection.rb:21 and datasource_zendesk/collections/base_collection.rb:27. Nothing else in the repo sets it: ActiveRecord and Mongoid leave searchable false, so the decorator enumerates and the guard works normally.

Raising is not available as a fix, though. searched_fields returns nil for every search on an RPC or Zendesk collection, extended or not. Failing closed there does not harden the feature, it removes it for those two datasources.

The option worth weighing is narrower: refuse only when the caller asked for an extended search and the layer cannot say. An extended search is an explicit request to traverse relations, so refusing an unverifiable one is proportionate, and a plain search keeps working. Its cost is a visible change to the documented replace_search exemption — a customer whose users tick extended search would start getting a 403.

That is a product decision rather than a commit, so it is PRD-1024 with the analysis, the two call sites, and the RPC caveat that the far-end agent enforces its own permissions only once it carries PRD-900 itself.

@qltysh

qltysh Bot commented Aug 21, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (17)

RatingFile% DiffUncovered Line #s
Coverage rating: B Coverage rating: B
...est_admin_agent/lib/forest_admin_agent/services/permissions.rb100.0%
Coverage rating: C Coverage rating: B
...st_admin_agent/lib/forest_admin_agent/routes/resources/list.rb100.0%
Coverage rating: A Coverage rating: A
...st_admin_agent/lib/forest_admin_agent/routes/resources/show.rb100.0%
Coverage rating: A Coverage rating: A
...ce_customizer/decorators/search/search_collection_decorator.rb100.0%
Coverage rating: A Coverage rating: A
..._admin_agent/lib/forest_admin_agent/routes/resources/update.rb100.0%
Coverage rating: A Coverage rating: A
...ib/forest_admin_agent/routes/resources/related/list_related.rb100.0%
Coverage rating: A Coverage rating: A
...t_admin_agent/lib/forest_admin_agent/routes/resources/store.rb100.0%
Coverage rating: A Coverage rating: B
...rest_admin_agent/lib/forest_admin_agent/utils/csv_generator.rb33.3%11-16, 21-23
Coverage rating: A Coverage rating: A
...est_admin_agent/lib/forest_admin_agent/routes/resources/csv.rb100.0%
Coverage rating: F Coverage rating: F
...st_admin_datasource_toolkit/decorators/collection_decorator.rb33.3%83-85
Coverage rating: A Coverage rating: A
..._agent/lib/forest_admin_agent/routes/resources/update_field.rb100.0%
Coverage rating: A Coverage rating: A
...est_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb90.9%200
Coverage rating: A Coverage rating: A
.../lib/forest_admin_agent/routes/abstract_authenticated_route.rb100.0%
Coverage rating: A Coverage rating: A
...dmin_agent/lib/forest_admin_agent/utils/query_string_parser.rb100.0%
Coverage rating: A Coverage rating: A
...lib/forest_admin_agent/routes/resources/related/csv_related.rb100.0%
Coverage rating: A Coverage rating: A
...t_admin_agent/lib/forest_admin_agent/routes/resources/count.rb100.0%
New file Coverage rating: A
...oolkit/lib/forest_admin_datasource_toolkit/utils/field_path.rb100.0%
Total93.3%
🤖 Increase coverage with AI coding...
In the `fix/prd-900-agent-read-permission-on-projected-collections` branch, add test coverage for this new code:

- `packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb` -- Line 200
- `packages/forest_admin_agent/lib/forest_admin_agent/utils/csv_generator.rb` -- Lines 11-16 and 21-23
- `packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/decorators/collection_decorator.rb` -- Line 83-85

🚦 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.

The guard read filters, sorts and searches on every route, but a count
applies no sort and a chart neither sort nor search, so a sort naming a
denied collection refused a request that field could never reach. Four
routes refused something they drop.

Each route now names what it consumes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant