fix(agent): serve only the columns of collections the caller may read - #1840
fix(agent): serve only the columns of collections the caller may read#1840PMerlet wants to merge 10 commits into
Conversation
1 new issue
|
A read was permission-checked on the root collection alone. Every column a projection, a filter or a sort reached through a relation path came back unchecked, so a role with `read` on `cards` and nothing on `holders` could ask for `holder:nationalId` — and get it. The filter is the sharper half: it never returns the column, but one row back or zero rows back answers a `starts_with` guess, which recovers the value character by character. 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. That also keeps a ManyToMany through-collection out of it, which contributes no returned column. What happens on a denial depends on who asked for the field: - named by the caller, through `fields[]` or `Forest-Projection` — refused, with every offending path in one message so a client drops them all and retries once; - never asked for — dropped from the projection. `ProjectionFactory.all` expands every column of every to-one relation when no `fields[]` is sent, so 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. They have no prunable equivalent — dropping a condition widens the result set, dropping a sort clause silently reorders it, and a grouped-by key is chart output. A leaderboard counting a relation is the one aggregation no path describes: its value traces back to no field, so the rule above sees nothing to check. What it exposes is the cardinality of the related collection, which is what `browse` governs on `/forest/<collection>/count` and on `/relationships/<name>/count` — so assert that, on the foreign collection rather than on the through-collection a ManyToMany aggregates. 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. Covers get, list, csv, list-related, csv-related, count, count-related and the chart routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0099781 to
9ea876f
Compare
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (17) 🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
hercemer42
left a comment
There was a problem hiding this comment.
Validator review — PR #1840
Spec (PRD-900): conforms on every route the ticket's What shipped section lists, and the three documented divergences (leaf-only checking, refuse-named / redact-default, no redaction announcement) are implemented as written — with one gap: the ticket's own read 3 oracle stays reachable through search, not filters, because the guard only inspects search when searchExtended is also set. See the must-fix on authorization.ts:112. The scope/segment exemption is implemented and tested as specified.
Claude Opus 5 (claude-opus-5): Should fix
Applies to: packages/agent/src/routes/modification/update.ts:40-46 (not in this diff)
PUT /forest/<collection>/:id re-lists the record with ProjectionFactory.all(this.collection) and serializes the result straight into the response, so a role with edit on cards and no read on holders gets holder.nationalId back from a no-op update. It is the same disclosure this PR closes on GET /forest/cards/:id, one HTTP verb away, and a role with edit almost always has it.
modification/delete.ts:43-45 and dissociate-delete-related.ts:119-122 accept filters on the same denied related paths — destructive, so a poor oracle, but the guard is absent there too. create.ts:41 serializes only caller-supplied PKs, so it does not leak.
Routing update.ts through redactProjection with explicit: false is a two-line change and closes the read-shaped half. The filter-shaped half on the modification routes is a follow-up worth naming in the ticket's Not in scope, since "serve only the columns of collections the caller may read" reads as a claim about the whole agent.
Claude Opus 5 (claude-opus-5): Should fix
Applies to: the PR as a whole
No ADR records the decisions this diff embeds, and agent-nodejs has no docs/adr directory at all — the org-wide corpus is four records, none touching permissions (control query verified, so this is an absence rather than a failed search). Three calls here are hard to reverse, surprising without context, and a real trade-off:
- explicitly named denied paths are refused while the implicit
ProjectionFactory.allexpansion is silently redacted — one class of request 403s, another is quietly narrowed; browsestands in forreadon aCountleaderboard;- rendering scopes, segments and a customized
replaceSearchare exempt.
PRD-900 carries the rationale and the comments at authorization.ts:55-60,88-92,148 carry part of it, but this is a cross-SDK contract — the ticket says agent-ruby should be built from it, and agent-python/php have no record either. Worth running /adr in this repo so the next implementer inherits the reasoning rather than the behaviour.
…wn syntax `relation.column:term` is documented end-user search syntax, and it needs neither `filters` nor `searchExtended`: `FieldsQueryWalker` rewrites the dot to a colon, and the search decorator resolves the result across relations — to-many ones included. So `?search=holder.nationalId:1850` reached a column of a collection the caller had no `read` on, and the guard pushed no usage at all. That is PRD-900's read 3 oracle, still open through a second door. Resolve those paths with the decorator's own resolver instead of a second approximation of it. `lenientGetSchema` moves out of `SearchCollectionDecorator` into a module the decorator now calls, and `getSearchedFieldPaths(collection, search)` is exported from the customizer — a string in, resolved field paths out, so no ANTLR-generated type reaches the public API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`assertCanReadQueryFields` is stubbed to a no-op and `redactProjection` to an identity passthrough in the route test factory, so the existing suites stay green whether or not a route calls either. The three `*-related` routes are the only sites resolving against `foreignCollection` rather than the route's own collection, and nothing would have caught a swap between the two; the Line, Value and Objective chart shapes went through `assertCanReadAggregatedFields` unasserted as well. Cover all of them against a real `AuthorizationService`, and make the four allow-side cases assert the clause that survived — a filter condition, the injected scope leaf, `search` and `searchExtended` — rather than that the request was not refused. Bare called-ness passes just as well when a permitted query is silently narrowed, which is the failure mode this change chose 403 over. Also scope the `withPks` note to the relation type it holds for: a ManyToOne already carries the re-added key on the row, a OneToOne does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard enumerated the collections an extended search could touch from the schema it holds — the top of the decorator stack. The search decorator reads `childCollection`, below the publication and renaming layers, so the two sets disagreed in both directions. A relation hidden by `.removeField` while its target stayed published was absent from the guard's view and still searched, so `searchExtended=1` reached its columns with no check. And a collection using `replaceSearch` was refused on `searchExtended` although its handler never runs the default enumeration at all, which the deliberate exemption for replaced searches was supposed to spare. Move the question, not the decision: `getSearchedFields(search, extended)` walks down the stack from `CollectionDecorator` and the search decorator answers it from `childCollection`, returning `null` when a replacer makes the fields the customer's choice rather than the caller's. Enforcement stays at the route, which is the only place a caller's own query is still separable from the scope and the segment the agent injects into the same filter. Each half is now pinned where it lives: the decorator's answer in the customizer's suite, the agent's use of it — refuse what is named, forward the caller's flag, serve when the stack cannot say — in the security suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The describe block named a symbol that does not exist, so a grep for the suite pinning the redact-vs-refuse policy found nothing. Also renames the chart guard's path argument, which is not always the collection whose name is passed as the permission root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PUT re-listed the row it had just written with a projection covering every to-one relation, so edit on a collection returned columns of collections the caller cannot read — the same disclosure the read routes now refuse, one HTTP verb away. The projection is agent-chosen there, so it is redacted rather than refused: a write must not fail because the row carries a relation the caller may not read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Replying to the two findings in the validator review body, which have no inline thread.
|
hercemer42
left a comment
There was a problem hiding this comment.
Validator re-review — PR #1840
Follow-up to run 1840-20260820144736-hercemer42, re-verified against bc3925a4. All nine inline findings from that review are addressed: seven fixed, one deferred to PRD-1002 with reasoning I accept, and the update.ts disclosure fixed in bc3925a4 — the exact-projection tests on both sides of the branch are the right way to pin it. The lenientGetSchema refactor is semantically identical to what it replaced. CI is green on this head.
Spec (PRD-900): the code conforms. The ticket does not — see the finding below; it is a documentation gap with a code consequence for the SDK ports, not a defect in this diff.
Claude Opus 5 (claude-opus-5): Should fix
Applies to: PRD-900
The ticket does not record the search fix. No mention of the relation.column:term dot syntax, of getSearchedFields, or of the oracle that motivated a88dbfed / 78944c53 — and What shipped still describes the implementation those commits replaced:
Filters, sorts, extended search, and a chart's group-by or aggregated field are always refused
A customized
replaceSearchis exempt, deliberately. Its condition tree is built insideSearchCollectionDecorator.refineFilter, long after the route has run its check, so a handler searchingholder:nationalIdis never verified.
Both sentences reason from a route that derives the check from the schema. The route no longer derives anything: it asks the stack, and the exemption is now the decorator returning null because it owns the decision — which is a better rule, stated nowhere.
This matters because of the argument for not adding an ADR, which I accept on its merits: PRD-900 carries the decisions, and it is what agent-ruby will be built from. It now carries three of four, and the missing one is the largest change in the follow-up. Built from the ticket as it stands, the Ruby port lands on the original design — enumerate to-one relations at the route, gate on searchExtended, never look at the search string — which is the hole this PR closed. The dot syntax is documented end-user behaviour (/product/execute/browse § Advanced search syntax), so the port would ship it reachable.
The other three follow-ups are recorded well: update in Routes covered, the dashboard leaderboard under Newly settled, the delete filters split to PRD-1012 in Not in scope. This is the one gap, and it is the one that carries a vulnerability into the next SDK.
An unresolvable prefix answered with the collection it was asked about, which the caller pins to readable — so "this path does not resolve" read as "this path is allowed". It now goes through the same resolver as the agent side, which throws. The two permissive search tests granted permissions they never used: the collection factory defines no getSearchedFields, so the guard saw an empty list and passed with no permissions at all. They now stub it, and fail when the grant is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5d1f85b to
2e3e7b5
Compare
`explicit` needed a doc comment to say what it was explicit about. The call site in update.ts now reads as its own explanation, and the comment is gone. Also drops the parts of the search comment that repeat what the two declaration sites document since the resolution moved down the stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment claimed a ManyToOne makes the re-added key redundant. That holds only when foreignKeyTarget is the primary key, which the schema does not require. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Three inline threads from the re-review are verified at Pushing back on the one body finding, which is a Should fix and still open. PRD-900 does not record the search fix. Re-checked just now: no mention of the
The second one is the sharper half, and it is not only stale wording — the reason is now wrong. The exemption no longer exists because the route's check runs too early; it exists because This is the same ticket you offered in place of an ADR, and I accepted that on its merits — a corpus of one is worse than a ticket people read. The trade only holds while the ticket is complete. It now carries Concretely, what agent-ruby needs from What shipped: the route asks the collection what a search will reach rather than deriving it from the schema; the answer is resolved below the publication and renaming layers; |
The guard read filters, sorts and searches on every route, but ContextFilterFactory.build carries no sort — only buildPaginated adds one. So count, count-related and the chart routes refused a sort naming a denied collection on a request that sort never reached. Each route now names what it consumes. The default stays all three, so a new route is checked fully until it says otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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
readoncardsand nothing at all onholdersgets this in full:The header is not even required —
GET /forest/cardswith nofields[]returns the same columns, becauseProjectionFactory.allexpands 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:
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:nameneedsreadonorganizationsalone, 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:
fields[],Forest-Projection)ProjectionFactory.alldefault)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.
One case the rule does not describe
A leaderboard counting a relation has no aggregate field, so no path traces back to the collection being counted. What it exposes is that collection's cardinality, which is what
browsealready governs on/forest/<collection>/countand/relationships/<name>/count— so that is what gets asserted, on the foreign collection rather than on the through-collection a ManyToMany aggregates.Found by an adversarial review pass on the first revision of this branch, not by the original implementation.
Routes covered
get,list,csv,list-related,csv-related,count,count-related, the chart routes, andupdate.updateis a write, and in scope anyway.PUT /forest/<collection>/:idre-lists the row it just wrote withProjectionFactory.alland serializes the result, so a role witheditoncardsand noreadonholdersgotholder.nationalIdback from a no-op update — the same disclosure asGET /forest/cards/:id, andeditalmost always comes with it. The projection there is entirely agent-chosen, so it is redacted, never refused: a write must not 403 because the row happens to carry a relation the caller cannot read.The filter-shaped half on the destructive routes —
delete.ts:45anddissociate-delete-related.ts:122accept the caller'sfilterson the same denied paths — is deliberately out of scope and split to PRD-1012. A delete answers204whatever it matched, so there is no response oracle and each probe destroys the rows it measures, while refusing would 403 any bulk delete filtered on a belongsTo sub-field, which the filter bar allows and no front change absorbs.Sequencing — the front has landed
The front used to ask for these columns on every list and detail view. It stopped in ForestAdmin/forestadmin#9914, which prunes projections by
canReadCollectionand renders a denied belongsTo as restricted — merged intomainon 2026-08-21 (d050ef96). That was the blocker on this PR: merging the agent first would have turned every list or detail view showing a belongsTo on a denied collection into a 403.One path the front does not cover, by design: chart requests. See the dashboard leaderboard note under Residual cost.
Residual cost
Roles that display a related label today without
readon 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. A
Countleaderboard onholderscountingcards, for a role withoutbrowseoncards, now returns 403 where it returned counts — and the widget still renders, since dashboard chart visibility follows the chart's own collection, so it shows an error rather than disappearing. ForestAdmin/forestadmin#9914 prunes record, list and export projections and does not touch chart requests, so the sequencing note below does not cover this path. It needs the same permission sweep as the rest, onbrowserather thanread.Any client requesting relation fields its role may not read — a customer script, an
agent-clientintegration, an MCP tool — 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.Not in scope
browsestill stands in for a deniedreadwhen the front resolves a get-one through the list route — tracked in PRD-990.instantCacheRefresh: false, each denied check clears and refetches the whole permission cache; denials are routine on the redaction path, so that is one permission fetch per read — tracked in PRD-1002, to be fixed inforestadmin-clientrather than here.getSearchedFieldswalks down the stack and the search decorator answers fromchildCollection, so a field hidden by.removeFieldis still checked and a replaced search is no longer refused.readof its own is left open, as PRD-900 states.Tests
test/security/related-read-permissions.test.tsbuilds the ticket's schema and role, and each case fails against the previous implementation — verified by reverting the guard, not assumed.🤖 Generated with Claude Code
Note
Enforce read permissions on query fields and projections across agent routes
AuthorizationService.redactProjectionto drop or reject projection fields whose owning related collections the caller cannot read; implicit projections drop fields, explicit field lists return 403AuthorizationService.assertCanReadQueryFieldsto reject filter/sort/search terms that reference fields from unreadable related collections, applied to list, get, count, csv, chart, and related routesFieldPathUtils.getLeafCollectionto resolve the owning collection of a colon-separated field path, andSearchCollectionDecorator.getSearchedFieldsto surface which fields an extended search will readCsvGenerator.filterHeaderto keep CSV header labels aligned with redacted columns;UpdateRoute.handleUpdatenow redacts unreadable fields from the post-update responseredactProjectionin authorization.ts and route handlers in packages/agent/src/routes/access/ for regressions in existing API consumersChanges since #1840 opened
explicitproperty tonamedByCallerin theRequestedProjectiontype and updated all usages throughoutAuthorizationService.redactProjection,QueryStringParser.parseProjectionFromHeaderOrQuery,UpdateRoute.handleUpdate, and associated tests [680ec65]related-read-permissions.testtest suite [aeb3c3c]AuthorizationService.assertCanReadQueryFieldsmethod to accept optionalconsumesparameter of typeQueryComponent[]with default value['filter','sort','search'], and added exportedQueryComponenttype union andALL_QUERY_COMPONENTSconstant [9385592]ChartRoute.makeChart,CountRelatedRoute.handleCountRelated, andCountRoute.handleCounthandlers to pass['filter','search']as third argument toassertCanReadQueryFields[9385592]Macroscope summarized 2e3e7b5.