Skip to content

Commit 1571aed

Browse files
fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917) (#20931)
Fixes #20917 Clause-②: yes (narrowing) One admission gate now judges every member an analytics query names against the caller's field-level read permissions, before either strategy runs. A member the caller may not read answers the engine's own refusal: `403 PERMISSION_DENIED`, in the engine's words. The gate sits at the analytics door (`AnalyticsService.callCtx`, right after the object-level admission and before strategy selection), so `NativeSQLStrategy`, `ObjectQLStrategy`, the SQL echo and any strategy added later inherit it by construction. There is no second copy of the permission rule: which fields are readable is the `security` service's answer, and the analytics layer contributes only the resolution from member to field. ## Why line 2 is `yes (narrowing)`, not the claim's expected `no (narrowing)` Both directions measured: - **Narrowing.** On the native-SQL strategy every position in the table below was answered and is now refused. On the ObjectQL strategy an order key naming a hidden field was ignored and is now refused, a joined filtered member moves from `400 INVALID_FIELD` to the engine's refusal, and the SQL echo printed the statement and now refuses. - **Widening.** `AnalyticsServiceConfig` gains one optional member, `getReadableFields`, the hook `AnalyticsServicePlugin` fills. It is a new member of a published config type, so the public surface grows by one hook. No query accept set widens. (There is no matching plugin option: nothing in the tree passes the object-level sibling `admitObjectRead` either, so the plugin always bridges to the `security` service.) `@objectstack/service-analytics` ships `minor` with the BREAKING banner and an ADR-0087 `not-required (no-migration-prescription)` disposition. `check-adr-0087-registration` and `check-changeset-no-major` pass. ## Per position Measured on SQLite with the real `SecurityPlugin`, `ObjectQL` and `SqlDriver`, as a member whose permission set hides fields on the queried object and on a related one. "Refusal" means `403 PERMISSION_DENIED` whose code, status and message equal what the engine answers for the same field as the same caller: `engine.aggregate` for a member the query groups or aggregates, `engine.find` for one it filters or sorts by. Each face is the cube read (`AnalyticsService.query`, what `POST /api/v1/analytics/query` relays) and, per row, the dataset door (`POST /api/v1/analytics/dataset/query`) where the position exists there. | position | native-SQL strategy: before → after | ObjectQL strategy: before → after | |:--|:--|:--| | grouped member (dimension) | answered, the hidden values as group keys → refusal | refusal → refusal | | aggregated member (measure) | answered, an aggregate over the hidden field → refusal | refusal → refusal | | bucketed time dimension | refusal (the strategy declines, the engine refuses) → refusal | refusal → refusal | | time-dimension window | answered, rows selected by the hidden field → refusal | refusal → refusal | | filtered member, including under `$or` / `$not` | answered, rows selected by the hidden field → refusal | refusal → refusal | | order key | answered, groups ordered by the hidden field → refusal | answered, the key ignored → refusal | | joined member, grouped: a dataset `include`, an authored join, an inferred cube's relationship path | answered, the related hidden values → refusal on the related object | refusal → refusal | | joined member, filtered | answered, rows selected by the related hidden field → refusal on the related object | `400 INVALID_FIELD` (cross-object filter) → refusal | | a dataset's own `filter`; a requested measure's own `filter` | answered → refusal | refusal → refusal | | authored cube alias over a hidden field: grouped, aggregated, filtered, joined | answered → refusal naming the field the alias resolves to | refusal (joined filtered: `400`) → refusal | | SQL echo (`AnalyticsService.generateSql`, what `POST /api/v1/analytics/sql` relays), every row above | printed the statement → refusal | printed the statement (joined filtered: `400`) → refusal | | readable members (the control) | answered → answered, unchanged | answered → answered, unchanged | The dataset door answers every refusal as `403` with `{ code, message }` and no rows beside it. ## The reader, and how the gate reaches it - `engine.find` and `engine.aggregate` refuse a hidden field in `plugin-security`'s middleware: the aggregate-input guard for a grouped or aggregated field, the predicate guard for a filtered or sorted one. Both build their mask from the caller's permission sets through `permissionEvaluator.getFieldPermissions`, the `requiredPermissions` fold and the on-behalf-of delegator intersection. - The published cross-package reader of that same derivation is `ISecurityService.getReadableFields(object, context)` (`resolveProjectionFieldMask`). It is called, not changed: no export or signature change in `objectql`, `spec` or `plugin-security`. - `AnalyticsServicePlugin` bridges `AnalyticsServiceConfig.getReadableFields` to the registered `security` service at call time, with the three resolutions its object-level and row-scope bridges keep apart. No security service: no field-level gate, as on `/data`. A service that throws on resolution or carries no `getReadableFields`: the query is refused, fail-closed, logged at `error`. Otherwise: ask it, once per object the query names a field of, with the caller's context. - The analytics layer adds the member resolution (`namedQueryFields` in `analytics-service.ts`, the judgement in the new `field-read-admission.ts`). Each member is resolved as the strategies resolve it (`declaredMemberEntry`). A joined member is resolved through the cube's join at each hop, and its relationship fields are judged on the object before them, as the engine judges a path's first segment. Filter members are read through the strategies' own lowering (`normalizeAnalyticsFilterTree` + `collectFilterLeaves`). - The words are the engine's two refusals, verbatim: the aggregate refusal for a grouped or aggregated member, the predicate refusal for a filtered or sorted one. On one object the aggregate refusal speaks first, and the base object before a joined one, as on the engine path. The draft-preview branch of the dataset door asks the same gate before it evaluates drafted rows. ## What is judged, and what is not - **Triage's floor:** dimensions, measures, filter members, joined members and time-dimension members. **Also order keys:** the native statement ordered by the named column. - **Authored versus inferred cubes:** a member is judged by the field its `sql` resolves to, never by its name in the cube. Both are measured above and pinned. - **A dataset's own `filter` and a requested measure's own `filter`: judged.** The nearest engine analogue, measured: the ObjectQL strategy hands both to the engine, which refuses a hidden field in either. On the inline dataset door the caller writes both. - **A host read scope: not judged.** The engine's field guard runs on the caller's own predicate before row-level policy is composed, and exempts policy predicates by design: they may name fields the caller cannot read. A read scope naming a hidden field is served, as on `/data`; pinned. - **Stand-downs, each pinned:** a member of an authored cube whose `sql` is an expression names no field the gate can attribute (flagged for a decision in the dev report); an object the reader answers "no answer" for has none of its fields judged, since an object the security service cannot resolve is one the engine serves nothing from; a name the object's declared field list does not carry is not judged, and with no field list available every name is. ## Pins, committed red ahead of the fix, pushed together with it - `77f73705a` pins, then `40c4979d8` the gate, then `992a592db` the changeset and the plugin tidy. - `packages/rest/src/analytics-field-permission-gate.test.ts`: both compositions over the real `SecurityPlugin`, `ObjectQL` and `SqlDriver`; the cube read and the SQL echo over the inferred cube and an authored cube, and the dataset door through this package's route; every refusal compared with the engine's live answer for the same field, computed in the same test. Readable members and a system caller are the controls. Against the base tree: 6 of 12 red (the three position tests per strategy), 6 green (the references and the controls). Now 12 of 12. - `packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts`: every position through both strategy paths from one table with nothing executed; the words and their order; the stand-downs; a throwing reader refused fail-closed; no reader wired; the draft-preview branch; and the plugin's bridge. With the three source files restored to the pins commit (by absolute path, the restore proven byte-identical to HEAD and `git diff HEAD` empty): 39 red, 9 green, the 9 being the stand-down and no-reader controls. Now 48 of 48. - The client census (`envelope-caller-census.test.ts`) counts no new call site: the pins call the producer through a receiver named `service`. Its suite passes unchanged. ## Ablation, predicted before running, at `40c4979d8` The door's gate call was replaced by a no-op through `scripts/ablation-replace.mjs` (anchor hit once, blob moved), `@objectstack/service-analytics` rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. Predicted: route pin 6 red and 6 green; unit pin 38 red and 10 green (the draft-preview branch keeps its own call, so it stays green). Observed: exactly that. The restore leg proved the blob equal to HEAD, `git diff HEAD` empty and the whole tree clean, rebuilt, and found the marker absent from all 6 built files. ## Verification, at `992a592db` - `@objectstack/service-analytics`: `test` 146 files, 3374 passed; `typecheck` exit 0, with 144 of 144 `__tests__` files in the tsc program (`--listFiles`). - `@objectstack/rest`: the 12 `analytics-*` route files plus `error-response-structured-arm-door-parity`, 13 files, 215 passed and 3 skipped; `typecheck` passes, including the test layer (`check:test-typecheck` OK). - Consumer sweep, narrowed to the test files that load this package: `@objectstack/runtime` `analytics-*` (3) plus `cross-field-refusal-operand-withhold`, 28 passed and 4 skipped; `@objectstack/client` `analytics-automation-json-erasure` plus the census, 27 passed; `@objectstack/dogfood` `analytics-*` (6 files), 54 passed. - Gates: `dispatch-gates --commands` derived 62. All 62 were run, plus the 4 roster families (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`), all exit 0. `dispatch-gates --ran`, fed each command with its exit code: 62 derived, 62 run, 0 NOT-MEASURED, a derived zero. `check:dual-build-cjs-loads` first exited 3 (prerequisite not met) and `check:type-check-debt` was cut by my own runner's time limit; both were re-run green after `turbo run build` over `./packages/*`. - Lint, narrowed and proved: the population is the 5 changed `.ts` files, none ignored by eslint's own config (`isPathIgnored` false for all 5). `eslint --no-inline-config` over them gives 5 files, 0 errors, 0 warnings. `parserOptions.project` and `projectService` are unset for all 5, so no type-aware rule runs and no untouched file's verdict can move. ## Docs No hand-written `content/docs/**` page states how the analytics routes treat field permissions. `permissions/authorization.mdx` states the engine's field guard in general terms and stays true; `permissions/index.mdx` names the analytics row-scope bridge only. ## Acceptance notes - NOT MEASURED: a live PostgreSQL cell. This package's test matrix runs no live SQL server, and the gate answers before a strategy is chosen and before any statement is compiled, so the dialect does not enter the verdict. - NOT MEASURED locally: the full `@objectstack/rest` `local` project and the whole-workspace typecheck. Left to CI. - The gate is exactly as wide as `getReadableFields`, the published reader. Where that reader and the engine's own field guard differ, the gate follows the reader; the difference is reported separately, as a class, in the dev report. (Reworded by the `domain:services` seat under the lane's disclosure discipline.) - A refusal names the field an authored alias resolves to, as the engine's refusal on the ObjectQL strategy already did for the same query. - An inferred cube's relationship path whose relationship field is not named after its target object names no object the security service knows, so none of its fields is judged. Neither strategy reads the related object there: the native statement fails and the ObjectQL strategy refuses, both unchanged. - `MemoryAnalyticsService` (driver-memory's own cube face) is not touched and not measured. - PR #20916 (#20887, the nested-relation form) holds `analytics-service.ts` and both strategies. It merges `main` after this lands, and the gate runs ahead of its nested-relation decline. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95555e7 commit 1571aed

6 files changed

Lines changed: 1193 additions & 1 deletion

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: every analytics face answers the engine's field-level read refusal, whichever strategy serves the cube: a field the caller may not read is judged before either strategy runs (#20917)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses analytics queries that read a field the caller's field-level permissions hide, which the engine already refuses on the data API and on the ObjectQL strategy, so there is nothing for `objectstack migrate meta` to rewrite. The one public-surface addition is a new optional service hook. -->
10+
11+
**BREAKING for analytics queries on a SQL deployment that read a field the caller may not read.**
12+
13+
**What changed.** `POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`
14+
and `POST /api/v1/analytics/dataset/query` now judge every field a query reads
15+
against the caller's field-level read permissions before a strategy is chosen:
16+
dimensions, measures, time dimensions, filter members, order keys, members
17+
joined through a relationship, and a dataset's own and its requested measures'
18+
filters. A member of an authored cube is judged by the field it resolves to,
19+
not by its name in the cube. A field the caller may not read answers
20+
`403 PERMISSION_DENIED`, in the words the engine uses for the same field. The
21+
native-SQL strategy, the one a SQL driver serves first, answered such queries;
22+
the ObjectQL strategy and the data API already refused them.
23+
24+
**What is not affected.** A query that reads only fields the caller may read
25+
answers as before. A system context, and a caller with no permission sets, are
26+
unaffected, as on the data API. A host read scope (row-level policy) may still
27+
name fields the caller cannot read. A deployment with no security service applies
28+
no field-level check, as on the data API. A member of an authored cube whose `sql`
29+
is an expression is not attributed to a field.
30+
31+
**New hook.** `AnalyticsServiceConfig.getReadableFields(object, context)` supplies
32+
the reader. `AnalyticsServicePlugin` wires it to the `security` service's
33+
`getReadableFields`; a host that constructs `AnalyticsService` itself passes its
34+
own, and without one no field-level check applies.
35+
36+
**If a widget stopped answering for some users,** it reads a field those users
37+
may not read. Grant that field's read permission to the users who need it, or
38+
build the widget on fields they can read.

0 commit comments

Comments
 (0)