Skip to content

Commit 5f6b63a

Browse files
fix(service-analytics)!: an object read through a relationship path joins the one admitted and scoped object set (#20933) (#20962)
Fixes #20933 Clause-②: no (narrowing) ## What this changes The analytics door asks two questions over one object set before either strategy runs: the object-level read admission, and the read scope each strategy applies to the objects it reads. That set held the cube's base object and the joins the cube declares (`joins`, or a dataset's `include`). An object a query reaches through a relationship path the cube does not declare was not in it, although both strategies read that object. The class: **a related object reached through a relationship path was read without its admission or its row scope** on the native-SQL strategy, and was not asked for at the door on the ObjectQL strategy. Every such object now joins the one set (`AnalyticsService.queryObjects`). It is admitted and row-scoped exactly as the base object and a declared join are, through the same mechanism, on both strategies and on every analytics face (the cube read, the SQL echo and the dataset door). There is no per-strategy copy of the rule and no scope applied only inside the native join: the strategies apply the scope of every object they read from the set they are handed. Three source files change, all in `@objectstack/service-analytics`: - `analytics-service.ts`: `queryObjects` adds, per hop, the object each member the query names reaches through a relationship path. The hop's object is read from `namedQueryFields`, the field gate's own resolution (PR #20931), reused rather than re-derived. `callCtx` derives the set once and hands the same value to the admission and to the read-scope pre-pass, and passes it to the strategy as `readScopedObjects`. - `strategies/native-sql-strategy.ts`: the cross-field decline (a read scope carrying a field reference routes the query to the engine path) reads the scopes over the door's set, rather than re-deriving base plus declared joins. - `strategies/types.ts`: declares `readScopedObjects` on the package-internal `DatasetScopedStrategyContext`. It is not exported: the built declaration file carries no hit for it. ## Per position (disclosure-safe: classes, no request shapes) Reference: the ObjectQL strategy's answer, and the same question asked through a declared join to the same object. | Position | Strategy | Before (class) | After | |---|---|---|---| | Inferred cube: a dimension through a relationship path | native | related object read with no admission and no row scope | unreadable related object: `403 PERMISSION_DENIED` naming it, before any statement runs; related rows outside the caller's scope are not read | | | ObjectQL | refused by the engine's own admission (its generic refusal); the door never asked | the door's refusal naming the object (same code and status, differs in form); scope unchanged (rows outside it grouped as restricted) | | Inferred cube: a filter member through a path | native | filtered on related values with no admission and no row scope | refused as above; a related value outside the scope matches nothing | | | ObjectQL | `400 INVALID_FIELD` (the strategy cannot filter across objects) | unreadable related object: the door's `403`; otherwise the same `400` | | Inferred cube: a time-dimension window through a path | native | windowed on related values with no row scope | refused, or scoped, as above | | | ObjectQL | `400 INVALID_FIELD` | unreadable related object: the door's `403`; otherwise the same `400` | | Authored cube: a dimension or measure over a relationship its `joins` does not list | native | read with no admission and no row scope | refused, or scoped, as a declared join is | | | ObjectQL | engine refusal / engine scope | the door's refusal; scope unchanged | | Authored cube: a path the query names itself | both | as the two rows above | as the two rows above | | Two-hop path (first hop falls back to its alias, second hop keyed by the cube's join) | native | the first hop's object read with no admission and no row scope | each hop admitted and scoped on its own object | | | ObjectQL | `400 INVALID_FIELD` (single hop only) | an unreadable hop: the door's `403`; otherwise the same `400` | | SQL echo of any row above | both | statement echoed with no admission of the related object | refused as the read is; on the native strategy the echoed statement carries the related object's scope clause, as a declared join's does | | Dataset door: a related object named through a relationship the dataset does not declare | native | `400 DATASET_INVALID` (the native compile's own refusal) | unreadable related object: the door's `403`; otherwise the same `400` | | | ObjectQL | engine refusal | the door's refusal | | Control: a readable related object | both | answered | answered, within the caller's row scope | **E4, native "after" against the reference.** The native answer now equals the native answer for the same question through a declared join, exactly: the same refusal envelope, and the same scope clause on the joined object. Against the ObjectQL reference it **differs in form** on out-of-scope related rows only. Native applies the joined object's scope as a predicate over the join (ADR-0021 D-C), so a base row whose related record is outside the caller's scope drops out of the answer. ObjectQL groups such a row as restricted. Neither reads the related value. This is the pre-existing declared-join form on each strategy, and this PR does not change it. ## Mechanism readings (E1 to E3) - **E1, the set** (`analytics-service.ts` at HEAD: admission `:1449`, `queryObjects` `:1575`, `cubeObjects` `:1594`, `resolveReadScopes` `:1683`). At base `1571aedce`, `queryObjects` returned `cubeObjects(cube)`: the base object plus every declared join. - An inferred cube's relationship path: the base object only. The admission asked for one object, and the scope pre-pass resolved one object. - An authored cube's declared join: base plus the join's object. - A dataset's `include`: base plus each included object, since the compiled dataset carries it as a declared join. - After the fix, each case also carries every hop's object. Admission and the scope pre-pass read the same value, and a unit pin asserts that the two sets are equal. - **E2, the native join.** `qualifyAndRegisterJoin` (`native-sql-strategy.ts:740`) registers one join per path prefix, aliased by the path with dots as `__`. The build loop (`:617`–`:624`) already applied the read scope for the base object and for every registered join, whether synthesized or declared, through `applyReadScope`. It reads `getReadScope` for that join's object: the same mechanism a declared join uses. - Before the fix, the pre-resolved scope map had no entry for a path object, so `getReadScope` answered nothing and no predicate was applied. - The fix changes the set and leaves the application path alone. The one strategy line that re-derived the object list, the cross-field decline (`:340`), now reads the door's set. - **E3, the object a path reads.** Hop by hop, through `namedQueryFields` (PR #20931's resolution): the cube's join keyed by the path with dots as `__`, falling back to the alias itself. Pinned on a two-hop path whose first hop falls back and whose second is keyed: the admitted set is exactly base, first hop and second hop. The route pin serves the two-hop case on the native strategy. The ObjectQL strategy serves a single hop only. - **E5, containment.** The fix lands within this card on both strategies, so routing restricted callers through the ObjectQL strategy was not needed and is not proposed. - **E6, `Clause-②`.** Measured in both directions. - Widening: none. - The public type surface is unchanged: `readScopedObjects` has 0 hits in the built `dist/index.d.ts`, against 19 for `StrategyContext` as a positive control, and the context type is not exported. - No probed position moved from refused to answered. - The native decline set is a superset of what it was: the same base and declared-join derivation, plus the path objects. - Narrowing: yes. Positions move from answered-unadmitted to `403`, and from unscoped to scoped. - Line 2 reads `declared · no · narrowing` through `scripts/pm/clause2-line.mjs`. The changeset is `minor`, carries the BREAKING banner, and carries its ADR-0087 marker (`not-required (no-migration-prescription)`). ## Pins, red and green, and ablation - **Unit pin** `packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts` (27 cases), on both strategies. It covers the seven positions above, and for each one checks three things: - an unreadable related object is refused by name, on the read and on the echo, before anything runs; - admission and the scope pre-pass ask for one set, and that set carries the path's object; - the related object's scope reaches what each strategy executes. It also carries the two-hop resolution, the native decline, and the control. - **Route pin** `packages/rest/src/analytics-relationship-path-admission.test.ts` (28 cases). It runs over the shipped composition with the real `SecurityPlugin`, `ObjectQL` and `SqlDriver` (SQLite), once per strategy, and every answer is compared with the same question through a declared join. It covers: - an unreadable related object is refused; - related rows outside the caller's scope are not read; - a filter on an out-of-scope related value counts nothing; - the control; - the dataset door. - **Red before the fix.** Pins were committed at `9c12bad1c`, before the fix at `b48c42e1a`, and pushed only together with it. On the pins tree: unit 25 failed, 2 passed (the controls); route 20 failed, 8 passed (the fixture checks, the controls, and the ObjectQL positions that were already right). Ablation 1 below reproduces exactly that red set from the committed HEAD. - **Green at HEAD `fdbdc670d`.** Unit 27/27, route 28/28, read after a rebuild with the marker present in `dist/`. - **Ablation 1, the widened set.** The path-object loop in `queryObjects` was deleted through `scripts/ablation-replace.mjs`, which confirmed the anchor went from 1 to 0 and the blob changed. The package was rebuilt, and `ablation-dist-preflight --absent` confirmed the marker was gone from `dist/`. - Result: unit 25 failed / 2 passed, route 20 failed / 8 passed. Predicted in writing beforehand, and exactly the pre-fix red set. - Restore: the blob equals HEAD (`7e80788d9873`), `git diff HEAD` is empty, and porcelain is empty, re-proved by an outer trap by absolute path. After a rebuild the marker is present in `dist/` again, and the pins read unit 27/27, route 28/28. - **Ablation 2, the decline reads the door's set.** The strategy was made to ignore `readScopedObjects`. Result: unit 1 failed / 26 passed, exactly the decline pin, as predicted. Restore: the blob equals HEAD (`bc6e15abfc96`), `git diff HEAD` is empty, and the unit pin reads 27/27 afterwards. The unit pin imports source, so no build was involved. ## Tests and gates (HEAD `fdbdc670d`) - **Gate union, one locked sequential run on HEAD `fdbdc670dc`** (`git rev-parse --short HEAD`, printed by the run at start and end with an empty `git diff HEAD`). The run covered the 62 commands `dispatch-gates --commands` derives from this diff: 61 exited 0, and 1 is NOT MEASURED. - `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET. The gate reads every workspace package's built output, and 34 packages have no `dist/` in this worktree. For the one package this diff changes, a direct `require()` of its CommonJS entry loads, with 17 exports. CI runs the gate itself. - `dispatch-gates --ran` reconciliation: 62 derived, 61 run, 1 NOT MEASURED, 0 unrun (exit 0). - **The four roster families named at dispatch** are not printed by this diff's derivation. They were run anyway, and all exited 0: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`. - **Package suites at HEAD `fdbdc670d`.** - `@objectstack/service-analytics`: 147 files, 3401 tests passed. - `@objectstack/rest` (`--project local`): 244 files, 4893 passed, 114 skipped. - `@objectstack/client`: 50 files, 641 passed. - **The client census.** `envelope-caller-census.test.ts` is 20/20. Neither pin adds a censused call site: the census's own pattern has 0 hits in each pin, against 6 in a positive-control file. No ledger row is needed. - **Typecheck.** `@objectstack/service-analytics` and `@objectstack/rest` (its test-layer typecheck included) exit 0. Both pin files are in their package's typecheck program, counted with `--listFiles`. - **eslint, narrowed.** The whole-repo run is CI's. The 5 changed TypeScript files give 0 errors and 0 warnings, and eslint's own JSON output reports all 5 and none as ignored. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict. - **Build.** The dependency closure and `@objectstack/service-analytics` at HEAD built with exit 0. ## Acceptance notes - **Native and ObjectQL differ in form on out-of-scope related rows** (E4 above). Native drops the base row, and ObjectQL groups it as restricted. This is pre-existing on declared joins, and this PR leaves it unchanged. - **A relationship name that is not itself an object name** was never served: a 500 on the native strategy, and an engine refusal or a `400` on the ObjectQL strategy. For a caller the object-level check applies to, it now answers the door's `403` naming that relationship. That is the resolution PR #20931's field gate already uses. The changeset states it. - **The native decline's fallback.** The decline re-derives base plus declared joins only for a strategy context built without the door's set. The door always passes the set whenever it resolves read scopes, so no production path reaches the fallback. NOT MEASURED by a pin. - **The draft-preview branch** keeps using `cubeObjects` alone, because it evaluates the executor's queries over the drafted base rows in memory. NOT MEASURED by a pin here. - **A cube whose `sql` is not a bare object name** names no attributable field (`namedQueryFields`), so its set is unchanged by this PR. - **Drivers.** The route pin runs on SQLite only. The set is computed at the door, before any driver. - **`main` moved.** `origin/main` is at `f6ccca4a4`, 11 commits past this branch's base `1571aedce` (read just before this PR opened). None of them touches this claim's surface, so the branch is not merged, per the dispatch order. This PR's CI on the merge ref reads the merged tree. - `dispatch-gates` flagged three of its derivation inputs as changed on `main`: two ADR-anchor files for other packages, and the doc-authoring prose-id baseline. This branch adds tracker ids only in code comments, and the derived family list is unchanged. - **CI** is not awaited. The CI-only lanes `dispatch-gates` names (the test shards, dogfood, temporal conformance, build core, the workspace type-check lanes and the wide-population families) are CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83480c6 commit 5f6b63a

6 files changed

Lines changed: 737 additions & 26 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: an object an analytics query reads through a relationship path is admitted and row-scoped exactly as a declared join to it is, on both strategies (#20933)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses, or row-scopes, what the native-SQL strategy read from an object reached through an undeclared relationship path, the way a declared join to the same object already was; there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING for analytics queries on a SQL deployment that read a related object through a relationship path the cube does not declare.**
12+
13+
**What changed.** The analytics door admits and row-scopes one object set
14+
before either strategy runs. It held the cube's base object and the joins the
15+
cube declares (`joins`, or a dataset's `include`). An object reached through a
16+
relationship path the cube does not declare was not in it, although both
17+
strategies read that object: a dotted member of an inferred cube, an authored
18+
member whose `sql` walks a relationship the cube's `joins` does not list, or a
19+
dotted member the query names itself. Every such object is now in the set, so
20+
`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql` and
21+
`POST /api/v1/analytics/dataset/query` treat it exactly as a declared join:
22+
23+
- a related object the caller may not read answers `403 PERMISSION_DENIED`,
24+
naming that object, before any statement runs;
25+
- the caller's row scope on the related object is applied, so related rows
26+
outside it are not read. On the native-SQL strategy a base row whose related
27+
record is outside the scope drops out of the answer, as it already did for a
28+
declared join; the ObjectQL strategy still groups such rows as restricted;
29+
- a related-object scope the native-SQL strategy cannot compile routes the
30+
query to the ObjectQL strategy, as it already did for a declared join.
31+
32+
Each hop of a multi-hop path is judged on its own object, resolved the way the
33+
field-level gate resolves it: the join the cube keys by the path, or else the
34+
relationship name itself.
35+
36+
**What is not affected.** A query through a related object the caller may read
37+
answers as before, within the caller's row scope. A system context, and a
38+
caller with no permission sets, are unaffected, as on the data API. A
39+
deployment with no security service applies no object-level check, as on the
40+
data API.
41+
42+
**Refusals that change form.** On the ObjectQL strategy a related object the
43+
caller may not read was already refused; it now answers the analytics door's
44+
refusal rather than the engine's, the same one a declared join gets. A filter,
45+
a time window or a two-hop path through such an object moves from
46+
`400 INVALID_FIELD` to that `403`. A relationship path whose relationship name
47+
is not itself an object name was never served by either strategy; for a caller
48+
the object-level check applies to, it now answers `403 PERMISSION_DENIED`
49+
naming that relationship.
50+
51+
**If a widget stopped answering for some users,** it reads a related object
52+
those users may not read. Grant read access on that object to the users who
53+
need it, or build the widget on objects they can read.

0 commit comments

Comments
 (0)