Skip to content

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

Open
PMerlet wants to merge 5 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#1840
PMerlet wants to merge 5 commits into
mainfrom
fix/prd-900-agent-read-permission-on-projected-collections

Conversation

@PMerlet

@PMerlet PMerlet commented Aug 20, 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:nationalId,holder:dateOfBirth

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:nationalId","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.

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 browse already governs on /forest/<collection>/count and /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, and the chart routes.

Sequencing — do not merge before the front

The front asks for these columns today. It stops in ForestAdmin/forestadmin#9914, which prunes projections by canReadCollection and renders a denied belongsTo as restricted. That PR is green but still open. Merging this first turns every list or detail view showing a belongsTo on a denied collection into a 403.

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. A Count leaderboard on holders counting cards, for a role without browse on cards, 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, on browse rather than read.

Any client requesting relation fields its role may not read — a customer script, an agent-client integration, 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

  • browse still stands in for a denied read when the front resolves a get-one through the list route — tracked in PRD-990.
  • On 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 in forestadmin-client rather than here.
  • Nothing else known. The extended-search sweep no longer derives its own set: getSearchedFields walks down the stack and the search decorator answers from childCollection, so a field hidden by .removeField is still checked and a replaced search is no longer refused.
  • Whether a ManyToMany through-collection needs read of its own is left open, as PRD-900 states.

Tests

test/security/related-read-permissions.test.ts builds 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 related collection fields across access routes

  • Adds AuthorizationService.redactProjection to drop unreadable related fields from implicit projections or return 403 when those fields are explicitly requested
  • Adds AuthorizationService.assertCanReadQueryFields and assertCanReadUsages to reject filters, sort, and extended search that traverse collections the caller cannot read
  • Adds FieldPathUtils.getLeafCollection to resolve the owning collection of a field path by traversing to-one relations
  • Applies these checks to chart, count, CSV, get, and list routes (including related variants); chart aggregations verify both group-by and aggregated fields, and leaderboard Count now requires browse permission on the foreign collection
  • parseProjectionFromHeaderOrQuery now returns an explicitness flag so callers can choose redaction vs. rejection; CsvGenerator.filterHeader prunes CSV labels to match the redacted projection
  • Behavioral Change: requests that previously returned data from unreadable related collections now return 403 (explicit fields or query usage) or silently omit those columns (implicit projections); reviewers should verify redactProjection in authorization.ts and route handlers in routes/access/ handle the ForbiddenError path correctly

Changes since #1840 opened

  • Modified AuthorizationService to validate read permissions on each field path explicitly referenced in relation.column searches and to assert extended-search traversal permissions only on to-one relations when both a search string and searchExtended flag are present [d90b26c]
  • Created a new field-paths module in @forestadmin/datasource-customizer exposing lenientGetSchema and getSearchedFieldPaths utilities for resolving field paths across to-one and to-many relations from search strings [d90b26c]
  • Refactored SearchCollectionDecorator to use the imported lenientGetSchema helper from the field-paths module instead of maintaining a private implementation [d90b26c]
  • Exported getSearchedFieldPaths from the @forestadmin/datasource-customizer public API [d90b26c]
  • Added test coverage for search authorization, related-route permission enforcement, projection redaction, and chart aggregation denials [d90b26c]
  • Modified AuthorizationService.assertCanOnCollection to determine searched fields and collections by calling getSearchedFields on the collection decorator [78944c5]
  • Introduced SearchedField type and getSearchedFields method to the decorator system, including implementation in SearchCollectionDecorator and supporting utilities [78944c5]
  • Updated authorization and search decorator tests to verify getSearchedFields-driven behavior [78944c5]
  • Renamed parameter in ChartRoute.assertCanReadAggregatedFields method [46659eb]
  • Renamed test describe block for authorization service projection redaction [46659eb]

Macroscope summarized 9ea876f.

@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

PRD-900

@qltysh

qltysh Bot commented Aug 20, 2026

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 5): makeChart 1
qlty Structure Function with high complexity (count = 16): lenientGetSchema 1
qlty Structure Deeply nested control flow (level = 4) 1

Comment thread packages/agent/src/services/authorization/authorization.ts Outdated
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>
@PMerlet
PMerlet force-pushed the fix/prd-900-agent-read-permission-on-projected-collections branch from 0099781 to 9ea876f Compare August 20, 2026 13:20
@qltysh

qltysh Bot commented Aug 20, 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: A Coverage rating: A
packages/agent/src/routes/access/count-related.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/services/authorization/authorization.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/list-related.ts100.0%
Coverage rating: A Coverage rating: A
...ages/datasource-toolkit/src/decorators/collection-decorator.ts0.0%44
Coverage rating: A Coverage rating: A
packages/agent/src/utils/query-string.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/get.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/utils/csv-generator.ts100.0%
Coverage rating: A Coverage rating: A
packages/datasource-toolkit/src/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/csv.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/chart.ts100.0%
Coverage rating: A Coverage rating: A
packages/datasource-customizer/src/index.ts100.0%
Coverage rating: A Coverage rating: A
...ages/datasource-customizer/src/decorators/search/collection.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/csv-related.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/count.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/list.ts100.0%
New file Coverage rating: A
packages/agent/src/utils/field-path.ts100.0%
New file Coverage rating: A
...ges/datasource-customizer/src/decorators/search/field-paths.ts100.0%
Total99.1%
🤖 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/datasource-toolkit/src/decorators/collection-decorator.ts` -- Line 44

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

@hercemer42 hercemer42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.all expansion is silently redacted — one class of request 403s, another is quietly narrowed;
  • browse stands in for read on a Count leaderboard;
  • rendering scopes, segments and a customized replaceSearch are 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.


if (
QueryStringParser.parseSearch(collection, context) &&
QueryStringParser.parseSearchExtended(context)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Must fix — the filter oracle PRD-900 read 3 describes stays open through search, which this guard only inspects when searchExtended is also set.

GET /forest/cards/count?search=holder.nationalId:1850&timezone=UTC returns a count for a role with browse on cards and zero permissions on holders. No filters, no sort, no searchExtended — so assertCanReadQueryFields pushes no usages at all and passes. Extend the value one character at a time and you read holders.nationalId out of a collection the caller has no read on; AND in the same string narrows it to one holder. Same on /forest/cards, .csv, the related lists and the Value/Objective charts.

relation.childProperty:term is documented end-user syntax (/product/execute/browse § Advanced search syntax) and is deliberately independent of extended search:

  • search/collection.ts:84extractSpecifiedFields(parsedQuery) runs unconditionally; options.extended gates only defaultFields at :88.
  • search/custom-parser/fields-query-walker.ts:9 — the name is turned into a path (.:).
  • search/collection.ts:139-164lenientGetSchema resolves it fuzzily (normalizeName) through ManyToOne, OneToOne and OneToMany, at any depth.
  • search/custom-parser/condition-tree-query-walker.ts:131-155 — emits a leaf on that path alone; build-string-field-filter.ts:9-13 prefers IContains, i.e. a substring oracle rather than equality.

Live on the standard SQL agent: datasource-sequelize/.../model-to-collection-schema-converter.ts:156 sets searchable: false, so SearchCollectionDecorator.refineFilter takes the "implement search ourselves" branch — the ANTLR path is the normal one, not a fallback.

test/security/related-read-permissions.test.ts:296"should accept a plain search, which never leaves the root collection" — pins the premise with search: 'martin', which contains no : and never reaches the property-matching branch.

The check has to reuse the decorator's own resolution (extractSpecifiedFields + a lenientGetSchema equivalent) and assert read on each resolved leaf regardless of searchExtended. A name-equality allowlist built from the top-level schema will not match, because that resolution is fuzzy and runs below the rename decorator.

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.

Confirmed and fixed — the hole was real, and I had dismissed it once on a bad check.

My first pass ran holder:nationalId:1850 through the ANTLR grammar, which rejects it, and I concluded there was nothing here. The documented form is the dot syntax, and FieldsQueryWalker normalises . to : before the fields are resolved — so the oracle was reachable exactly as you wrote it, with no filters, no sort and no searchExtended.

The guard no longer derives anything at the route. CollectionDecorator.getSearchedFields(search, extended) returns the paths a search will actually reach and the collection each one ends on; assertCanReadQueryFields turns those into read checks. a88dbfe, then 78944c5 which moved the resolution itself down into the search decorator.

Pinned by should refuse whatever the stack says the search will reach and should ask the stack with the extended flag the caller sent.

QueryStringParser.parseSearchExtended(context)
) {
for (const [name, field] of Object.entries(collection.schema.fields)) {
if (field.type === 'ManyToOne' || field.type === 'OneToOne') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Should fix — this sweep enumerates the wrong set, in both directions.

Under-inclusive, and it is a leak. It reads collection.schema.fields at the top of the decorator stack — above PublicationDataSourceDecorator and RenameFieldCollectionDecorator (decorators-stack.ts:71-72) — while SearchCollectionDecorator sits below both (:49) and enumerates this.childCollection (search/collection.ts:88,121-137). A customer who calls .removeField('holder') on cards while holders stays published hides the relation from this loop but not from the search decorator, so searchExtended=1 still emits holder:<col> conditions and no read check ever fires. It is also to-one only, while lenientGetSchema traverses OneToMany as well.

Over-inclusive, on a collection the PR deliberately exempts. refineFilter hands the string to this.replacer and never calls getFields when a replaceSearch is installed (search/collection.ts:56-61), so a customer whose custom search only ever touches panLast4 now loses extended search entirely for any role missing read on any to-one target. Fail-closed, so not a leak — but it contradicts the ticket's replaceSearch exemption in the strict direction, and the route cannot currently tell a customized collection from a default one (schema.searchable is true either way).

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.

Same root cause as the oracle above, and closed by the same change — the route stopped enumerating anything, so both directions disappear rather than getting patched.

  • Under-inclusive. SearchCollectionDecorator.getSearchedFields answers against this.childCollection, which is what the search actually reads, so a field hidden by publication or renaming above it is still accounted for. Resolution goes through lenientGetSchema, so OneToMany is traversed too.
  • Over-inclusive. It returns null when a replacer is installed, and the caller must read null as "unknown", never as "none". The exemption argued in the Macroscope thread stands, but it is now expressed by the layer that owns the decision instead of being approximated one level up.

Four tests in datasource-customizer/test/decorators/search/collections.test.ts, plus should serve the request when the stack cannot say what a search reaches on the agent side.

collectionNames: string[],
): Promise<Map<string, boolean>> {
const toCheck = [...new Set(collectionNames)].filter(name => name !== rootCollectionName);
const allowed = await Promise.all(toCheck.map(name => this.canRead(context, name)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Should fix — on agents running instantCacheRefresh: false, every one of these denials clears the whole environment permission cache and refetches it from the Forest API.

canReadPermissionServiceWithCache.canOnCollectionActionPermissionService.can, which passes allowRefetch: !options.instantCacheRefresh (action-permission.ts:33); on a denial hasPermissionOrRefetch calls invalidateCache()permissionsCache.clear() and refetches before answering (:49-57, :134-138). The default is safe — options-validator.ts:42 defaults instantCacheRefresh to true, so allowRefetch is false — but instantCacheRefresh: false is the documented opt-out for deployments that cannot hold the SSE connection, and it is also the only configuration where permissionsCacheDurationInSeconds is honoured (options-validator.ts:66).

Before this PR a denied can() was a rare event: someone attempting an action they lack. This PR makes denial the steady state — PRD-900 expects roles to be missing read on related collections until an admin sweeps — so one unreadable relation in a layout turns into a cache wipe plus a full permission refetch on every list, get, CSV and chart request, process-wide for all concurrent users. Only observable as Invalidating roles permissions cache.. at Debug.

A read lookup that does not refetch on deny, or memoising the permission map per request, removes it.

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.

Accurate, including the part that matters most: the mechanism predates this PR, but this PR is what turns denial from a rare event into the steady state, so the cost is fairly attributed here.

Deferred to PRD-1002 rather than fixed on this branch, deliberately:

  • the fix belongs in forestadmin-clienthasPermissionOrRefetch wiping the cache on every deny is wrong for every caller, not just this guard, and it ships on its own release cycle;
  • memoising per request here would only cover the routes this PR touches and would leave the same storm on assertCanBrowse, assertCanEdit and the rest;
  • it is a performance regression on an opt-out configuration, not a correctness or security one, so it should not gate the security fix.

Already in: getReadPermissions dedupes collection names within a call, so a request costs one lookup per distinct collection rather than one per path.

}
}

/** The root is skipped: its own route already asserts `browse` on a listing, `read` on a get. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Preferential — the justification does not hold for the chart route, which is one of the callers.

chart.ts:50-51 asserts only assertCanExecuteChart; nothing asserts browse or read on this.collection. So for charts the unconditional [rootCollectionName, true] entry rests on the chart-hash check alone, not on a collection-level assert. Charts were never collection-permission-gated, so this is not a regression — but the comment tells the next reader (and whoever ports this to agent-ruby) that an assert exists where it does not.

Compounding it slightly: assertCanReadAggregatedFields (chart.ts:300-316) passes this.collection.name as the root while resolving paths against aggregatedCollection. That is correct for both leaderboard shapes today, but two unrelated collections arriving as arguments to the same call is worth a word in the comment.

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.

Both halves fixed.

The comment no longer claims an assert that does not exist. It now reads: "The root is skipped: browse gates a listing, read a get, and the signed hash a chart." — naming the actual guarantee on each route, including that the chart's rests on the hash rather than on a collection permission.

For the second half I renamed the parameter instead of adding a comment: assertCanReadAggregatedFields(context, pathCollection, fields). The two collections reaching the same call are now distinguishable by name — pathCollection is what paths resolve against, this.collection.name is the permission root — so the leaderboard call site passing aggregatedCollection reads as intentional. 46659eb.

// A count exposes the cardinality of the relation, which `/relationships/<name>/count` puts
// behind `browse`.
if (!aggregation.field) {
await this.services.authorization.assertCanBrowse(context, field.foreignCollection);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Should fix — this breaks dashboard leaderboards for existing roles, and neither the sequencing note nor the front PR covers that path.

A Count leaderboard on holders counting cards, for a role without browse on cards, now returns 403 where it returned counts. The chart is still rendered — dashboard chart visibility follows the chart's own collection (/product/manage/dashboards § Access control: "Users only see the charts they have access to based on their role's collection permissions") — so the widget shows an error rather than disappearing.

ForestAdmin/forestadmin#9914 prunes record, list and export projections; it does not touch chart requests, so "do not merge before the front" does not protect this, and "Residual cost" names only related labels in list and detail views. The assertion itself is right and matches count-related.ts:20; what is missing is that dashboards are in the blast radius with no front-side change to absorb it. Worth naming in the PR body and the ticket before this ships.

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.

Agreed, and now named in both places you asked for.

PR body — a paragraph under "Residual cost": a Count leaderboard on holders counting cards now 403s for a role without browse on cards, the widget still renders and shows an error rather than disappearing, and ForestAdmin/forestadmin#9914 does not touch chart requests, so the front-first sequencing does not cover this path.

PRD-900 — the same paragraph under "Newly settled: a leaderboard that counts", so agent-ruby inherits it rather than rediscovering it.

The assertion itself stays as you read it. Worth flagging separately to whoever runs the sweep: for this path it is browse, not read.

Authorizations.assertCanExecuteChart = jest.fn();
Authorizations.invalidateScopeCache = jest.fn();
Authorizations.canRead = jest.fn().mockResolvedValue(true);
Authorizations.assertCanReadQueryFields = jest.fn();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Should fix — these stubs mean the three *-related guards and two of the four chart shapes are asserted by nothing.

assertCanReadQueryFields is stubbed to a no-op and redactProjection to an identity passthrough, so test/routes/access/list-related.test.ts, csv-related.test.ts and count-related.test.ts exercise the absent guard and stay green whether list-related.ts:21, csv-related.ts:26 and count-related.ts:23 call it or not. The new security suite imports only Chart, Count, Csv, Get and List — no related route — and its chart cases cover Pie group-by and Leaderboard Count only, leaving makeLineChart (chart.ts:156) and computeValue (chart.ts:287, reached from Value and Objective) unpinned.

Those three routes are also the only sites passing this.foreignCollection rather than this.collection, so passing the wrong one there reproduces the exact cross-collection read this PR closes, with a fully green suite.

skills/conventions/testing.md#Cover error and edge paths, not only the happy path

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.

Fixed in d90b26c — by covering the routes rather than by un-stubbing the factory, which would have rewritten a large number of unrelated route tests for no gain here.

related-read-permissions.test.ts now imports ListRelated, CsvRelated and CountRelated and drives them through a real AuthorizationService, so passing this.collection where this.foreignCollection is required now fails the suite — which is the substitution those three routes are uniquely exposed to.

Chart coverage extended to the two unpinned shapes, makeLineChart and computeValue (reached from Value and Objective), alongside the Pie group-by and Leaderboard Count cases already there.

buildContext({ query: { filters: oracleFilter } }),
);

expect(list).toHaveBeenCalled();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Violates conventions — bare called-ness on all four allow-side cases (:248, :264, :305, :317) proves the request was not refused, not that the clause survived.

These are the only guards on the permissive half of assertCanReadQueryFields. If a later change to the guard or to ContextFilterFactory dropped the holder:nationalId condition, the injected scope, or search/searchExtended from the filter handed to Collection.list, all four still pass — and silently narrowing a permitted query is the failure mode this PR chose 403 over. Assert the filter instead: the condition tree at :248, the injected scope leaf at :264, search: 'martin' at :305, search: 'martin', searchExtended: true at :317.

The not.toHaveBeenCalled() assertions at :128, :222, :236 are fine — a call that must not happen has no arguments to assert.

skills/conventions/testing.md#Assert behavior with args and outputs

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.

Fixed. The four allow-side cases now assert the clause survived, not that the request escaped refusal:

  • filter: list.mock.calls[0][1].conditionTree matches the holder:nationalId leaf;
  • scope: the injected leaf is asserted in the tree handed to list;
  • search: { search: 'martin', searchExtended: true } on the filter, and getSearchedFields asserted to have been called with ('martin', true).

Agreed on not.toHaveBeenCalled() for the refusal cases — a call that must not happen has no arguments to assert. Left as they were.

buildContext({}, { 'forest-projection': 'id,account:organization:name' }),
);

// The `account:` primary keys `withPks` re-adds are already on the row as `cards.accountId`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Preferential — the justification is ManyToOne-only, and the test is the place someone will read it as the rule.

For card.account as a ManyToOne it is exactly right: account:id is cards.accountId, already on the row. For a OneToOne intermediate the key lives on the foreign side, so withPks (projection/index.ts:56-64) re-adds a primary key that nothing the caller may read carries — and Serializer.buildRelationshipsConfiguration (serializer.ts:110-119) emits it as relationships.<relation>.data.id, telling the caller that a record exists in a collection they were denied, and which one.

Narrow, and it follows from the leaf-only rule the ticket chose deliberately — so the ask is just to scope the comment to the relation type it is true for, since agent-ruby will be built from this reasoning.

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.

Scoped, as asked. The comment now reads:

withPks re-adds account:id, which a ManyToOne already carries on the row as cards.accountId. A OneToOne intermediate would expose a key the row does not carry.

The reasoning agent-ruby inherits now states the relation type it holds for, and names the OneToOne case as something the leaf-only rule leaves open rather than letting it read as covered.

});
});

describe('keepReadableProjection', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Opus 5 (claude-opus-5): Violates conventions — this block names a method that does not exist: every assertion inside it exercises AuthorizationService.redactProjection, and no keepReadableProjection symbol appears anywhere in packages/agent/src.

Anyone grepping for the suite that pins the redact-vs-refuse policy — the load-bearing decision in this PR — finds nothing. Rename to redactProjection.

skills/conventions/testing.md#Test name states the exact behavior it asserts

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.

Renamed to redactProjection in 46659eb. 26 tests in that file, all green.

PMerlet and others added 4 commits August 20, 2026 17:40
…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>
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.

2 participants