Repository navigation
Commit 1571aed
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
- .changeset
- packages
- rest/src
- services/service-analytics/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
0 commit comments