Repository navigation
fix(service-analytics): the field-level read gate refuses a cube member that names no field instead of standing down (#20965) - #21153
Conversation
…er that names no field A cube member whose sql is neither a column reference nor the row wildcard names no field the analytics field-level read gate can judge. The gate used to stand down on such a member and let the query run; it now refuses it with the gate's PERMISSION_DENIED / 403 envelope, ahead of the field verdicts on its object, wherever the gate judges that object. The wildcard still names nothing and is not refused. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…t names no field The case that pinned the stand-down loses its expression half; its other half (an object the reader has no answer for) stays. New pins: each position an expression member can take, a declared member with no sql string and a member the query names itself are refused PERMISSION_DENIED / 403 on both doors and both strategy paths before anything runs; no grant makes such a member judgeable; field members are judged as before; the row wildcard is the control; and the plugin's security bridge refuses a configured cube's expression member. The fixture is written around the parse, which refuses exactly the refused members. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…l of a member that names no field Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0a2352b5d8f961471f9831c6f716d0f78093a789 && git checkout 0a2352b5d8f961471f9831c6f716d0f78093a789
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e952cff578cc936daab3241570c4c2c36804bbd6 f6f10470b3929a9033e2ca8af54c7940e1781a19 && git checkout -B drift-repro e952cff578cc936daab3241570c4c2c36804bbd6 && git merge --no-ff f6f10470b3929a9033e2ca8af54c7940e1781a19
node scripts/docs-audit/affected-docs.mjs --json e952cff578cc936daab3241570c4c2c36804bbd6
|
…es to a non-column source (#21156) The member-shape door gate skipped the measure position: namedQueryFields drops an undeclared measure (the strategies refuse one the cube does not carry), so a caller-named measure that inference mints — its sql built from the caller's own text by inferMeasure — slipped the gate and reached the aggregate position of the native statement verbatim in the ungated tiers (no security service; an object the reader answers undefined for). The judged tier already refused it (the minted measure reads as a declared expression member and #21153 catches it); the ungated tiers did not. Judge the inferred source here too, against the same rule inference applies: a caller-named measure must reduce — after inferMeasure's suffix strip, the no-suffix default included — to count / '*' or to a column reference (bare identifier / relationship path), or it is refused with the same fieldReadUnjudgeableError (PERMISSION_DENIED / 403). Author-declared measures are untouched; a dotted non-qualifier measure is already refused by #5918 ahead of this. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
…e analytics query door in every tier (objectstack-ai#21156) (objectstack-ai#21173) Fixes objectstack-ai#21156 Clause-②: no ## Summary An analytics query path could carry **caller-supplied member text into the native statement, in a tier the field gate does not judge**. A member the caller names — in `dimensions`, `timeDimensions`, a `where` leaf, or an `order` key — that is neither a declared cube member nor a column reference (a field, a relationship path, or `'*'`) names nothing any field gate can attribute to a column. Where the field-level read gate (objectstack-ai#20917/objectstack-ai#20935/objectstack-ai#20965) does not judge the queried object, that member reached the SQL strategy as written. This refuses such a member **at the query door, in every tier, before a strategy compiles it** — `PERMISSION_DENIED` / 403, the *same* refusal the field-level gate reaches where it judges the object (objectstack-ai#20965's `fieldReadUnjudgeableError`), so there is **one judge, one shape, no new error code**. The cube author's own declared members are untouched: a declared expression member keeps the read gate's verdict where it applies and the parse's (objectstack-ai#20943) otherwise. ## The two tiers this closes (by class) The field-level read gate judges a member only where a reader answers for its object. Two tiers are outside that: 1. **No security service** — no field reader is wired, so the gate is a no-op. 2. **An object the reader answers `undefined` for** — the gate stands down for that object. In both, a caller-named non-column member previously reached the native statement unjudged. The new gate is **provider-independent**: it asks only whether the member names a column or a member the cube's author declared, so it applies in every tier. ## Shape of the fix - One check at the query door (`analytics-service.ts`, beside where the existence gates and `namedQueryFields` run), so both doors (`query` and `generateSql`) and both strategies inherit the verdict by construction. `native-sql-strategy.ts` is **not** touched. - The member is judged against the cube **as it existed before ad-hoc inference** — an inferred cube mints every caller member into itself, which would otherwise launder caller text into a "declared" member. A probe cube with the base object but no declared members stands in for the inferred case. - A dataset's own filter is author text and is deliberately not judged here; the existing gate judges it where it applies. - Runs **after** the cube/object-existence check, so a non-existent cube still answers 404 first. ## Pins and ablation `packages/services/service-analytics/src/__tests__/caller-member-column-reference-gate.test.ts` pins, in **each** tier (no security service; an object the reader answers `undefined` for) × each strategy × an ad-hoc and a registered cube × each query position: the member is refused `PERMISSION_DENIED` / 403 on **both** doors, with **nothing executed**. A judged object with a real column is the control (same refusal, one door earlier). Positive controls confirm a real column member and a bare count are served in every tier, and a non-regression pin confirms an author-declared expression member still reaches the strategy with no security service. Every refusal pin was **ablated**: with the door gate disabled on disk, the 32 tier pins turn red (the member reaches the strategy) while the control, positive and non-regression pins stay green; restored and re-run green. The ablation used the repo's on-disk-verified mutation tool. ## Validation - `pnpm --filter @objectstack/service-analytics typecheck` — pass. - `pnpm --filter @objectstack/service-analytics test` — 158 files, 3648 passed, 21 skipped, 0 failed (at `31a582c9`). - The dispatch-derived gate families (`scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`): 62 derived, 62 run, 61 pass; `check:dual-build-cjs-loads` reports PREREQUISITE NOT MET (it reads a full-repo build this checkout did not produce) — left to CI, not a measured failure. ## Acceptance notes - The ADR-0037 **draft-preview** path (`answerDataset`'s in-memory branch) evaluates a selection over seed rows in memory and does not reach this door's gate. It compiles **no** native statement, so it is not a native-statement vector; it keeps the field-level read gate it already runs. Boundary noted, not extended. ## Patch round 1 (`130e8c0c`): the measure position, and the census Added by the reviewing seat (`domain:services` seat 2, objectstack-ai#21118), from the dev's round-1 report. The dev writes the PR body once. **The measure position.** The door gate now also judges a caller-named measure. After the inference rule strips its suffix (the no-suffix default included), it must reduce to `count` / `'*'` or to a column reference (a bare identifier or a relationship path). Otherwise it is refused with the same `fieldReadUnjudgeableError` (`PERMISSION_DENIED` / 403): one judge, no new error code. - The judged tier already refused it: the minted measure reads as a declared expression member, and objectstack-ai#21153's gate catches it. - The ungated tiers did not refuse it. This round closes them. - Author-declared measures are untouched. A dotted non-qualifier measure keeps objectstack-ai#5918's refusal, which fires ahead of this gate. - `native-sql-strategy.ts` is still untouched. **Census: every caller-supplied slot of an analytics query that could reach a statement.** | Slot | Verdict | |---|---| | `cube` | already judged: it must name a registered object, and a non-bare name is refused | | `measures[]` | judged by this PR (round 1), plus the existing source-field existence check | | `dimensions[]` | judged by this PR, plus the existing existence check | | `timeDimensions[].dimension` | judged by this PR, plus the existing existence check | | `timeDimensions[].granularity` | not free text: a closed enum, and any other value is refused by the schema | | `timeDimensions[].dateRange` | not an identifier: a closed preset vocabulary, or explicit bounds bound as parameters. An unrecognised string is refused `400` | | `where` leaves | the member is judged by this PR, plus the existing check; values are bound parameters | | `order` keys | the member is judged by this PR, plus the existing check; the direction is a closed enum | | `limit` / `offset` | numbers, not string slots | | `timezone` | not an identifier in the native statement: it resolves date ranges (parameterised comparands) and is forwarded to engine bucketing | | `segments` / `having` | not slots: the query schema is closed and declares neither | **Evidence for this round:** - Measure pins in each tier × strategy × ad-hoc and registered cube × the suffixed and the no-suffix forms: refused on both doors, with nothing executed. Positive controls, and an author-declared expression-measure non-regression pin. - The ablation of the measure refusal turns exactly the 16 ungated-tier measure pins red, while the judged-tier pins stay green (objectstack-ai#21153). - The package suite has 3679 passed. All 62 derived gate families pass, `check:dual-build-cjs-loads` included. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20965
Clause-②: no
What changes
The analytics field-level read gate no longer stands down on a cube member that names no field. A member whose
sqlresolves to neither a field the gate judges nor the row wildcard'*'is now refused with the gate's own envelope,PERMISSION_DENIED/403. The refusal comes before any strategy runs, on both the cube read and the SQL echo, and on both strategy paths. This is triage's correction5921856508as ruled: the gate fails closed, and such a member is never stood down and never passed.analytics-service.ts,fieldsOfColumnSql:'*'names nothing (an empty list, as before). Anything that is neither a bare identifier nor an identifier path now answers "not a column reference" instead of an empty list. The deleted arm is the one documented as "Anything else is an EXPRESSION the cube's author wrote".analytics-service.ts,namedQueryFields: such a member, or a declared member whosesqlis not a string, is carried to the gate as a member that names no field, on the cube's base object. An undeclared measure still names nothing, because both strategies refuse a measure the cube does not carry.field-read-admission.ts:assertNamedFieldsReadablerefuses such a member ahead of that object's field verdicts, through a new constructor in the same envelope, whatever grants the caller holds. No new error code is added. The refusal names the member and the object. It never carries the member'ssql, because echoing it would hand the cube author's text to a caller who was refused for not being judged able to read it.@objectstack/service-analyticspatch.Premise check (zone 2, measured at base
c6954d6d)fieldsOfColumnSql(its doc arm at:421at base; the card said about:409, the claim said:416). That is the field-level read gate's resolver.resolveMemberSourceis not part of that gate. It belongs to the two source-field EXISTENCE gates,assertDimensionFieldsandassertWhereFields(INVALID_FIELD/400, "does the object have this column"). Itssource: nullon an expression means "no column to check for existence", and it is left as it is. Those gates run first, inensureCube, and stand down on an expression. The refusal then comes from the field gate incallCtx.CubeRegistryhas two writers: configuration cubes (AnalyticsServiceConfig.cubes, fed byAnalyticsServicePlugin({ cubes }), which the CLI'sanalyticsCubescapability arg builds) and compiled datasets.registernever parses. So a cube configured before the parse refusal, or never put through it, reaches the gate as written. The fixture is built that way: the test file's authored cube, unparsed. A new pin asserts thatCubeSchemarefuses exactly its two expression members, plus one member with nosqlstring, and nothing else. A plugin-level pin passes the same cube throughAnalyticsServicePlugin({ cubes })with the security bridge.field-read-admission.ts:PERMISSION_DENIED, pinned against the standard catalog, with status403and theobjectproperty. No new code.resolveMemberSourceat base, bygit grep:assertDimensionFields(one site) andassertWhereFields(two sites), plus doc mentions inwhere-source-field-gate.test.ts. None consumes the field gate's verdict. The field gate's collectornamedQueryFieldshas two consumers.queryObjects(the object-admission and read-scope set) is unchanged, because a member that names no field adds only the base object, which is already in the set.assertFieldsReadablenow refuses.Measured before and after (unit probe through
AnalyticsService.query, not committed)At base, with a reader wired and answering, these positions were all served, and the native-SQL statement carried the member's
sqlas written: an expression member as a grouped dimension, an aggregated measure, a filter member and an order key. A member the query named itself that is not a column reference was served the same way, on a configured cube and on an inferred one. After the change, all of these are refusedPERMISSION_DENIED/403on both strategy paths, with nothing executed. With no reader, the behaviour is unchanged (see Acceptance notes).Pins
field-read-admission-gate.test.ts› "stands down where no field can be named: an authored expression member, and an object the reader has no answer for". It held the stand-down. The other half, an object the reader has no answer for, is kept, retitled "stands down for an object the reader has no answer for".field-read-admission-gate.test.ts, block "a member that names no field is refused, never stood down"):CubeSchemarefuses exactly the members the gate refuses);sqlstring, and a member the query names itself that is not a column reference. Each assertscode,status,objectandmember, that nothing executed, and that the message carries neither of the author's expression texts;'*'still counts (the control): a count beside a field member is served, and a count alone is served with the reader never asked;Ablations (each from committed head
f6f10470, throughscripts/ablation-replace.mjs: anchor hit once, blob changed, restored blob equal to HEAD,git diff HEADempty)The test file imports the service by relative path (
../analytics-service.js), so the ablated source is what runs and nodist/is involved.fieldsOfColumnSqlanswers an empty list for a non-columnsqlsqlone, no-grant, ahead-of, and the plugin pin. Failure shape:expected null to match object { code: 'PERMISSION_DENIED', …(3) }, andpromise resolved … instead of rejectingsqlnames nothingsqlrow, where the strategy then crashed with aTypeErrorfield-read-admission.ts)'*'arm removed, so the wildcard reads as an expression'*'control cases among themRestored state: 70 / 70 green.
Tests (final head
f6f10470)pnpm --filter @objectstack/service-analytics test: 156 files, 3560 passed, 21 skipped. At base, with only the source change, it was 2 failed (the stand-down pin, one row per strategy) and 3536 passed.pnpm --filter @objectstack/service-analytics typecheck: exit 0, and--listFilesincludes the edited test file.@objectstack/restanalytics-field-permission-gate,analytics-masked-field-gate,analytics-relationship-path-admissionandanalytics-hop-object-referencegave 4 files, 60 passed, against the rebuiltservice-analyticsdist.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 62 commands. All 62 were run, and--ranreconciled them as "62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN". 61 exited 0. The other one,pnpm check:dual-build-cjs-loads, exited 3: PREREQUISITE NOT MET, because it needs every package'sdist/and this worktree built only the analytics closure. It is NOT MEASURED and declared to CI.eslint --no-inline-config --format jsonover the 3 changed lintable files gave 3 files, 0 errors and 0 warnings atf6f10470.eslint.config.mjs's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block; the fourth changed file is the.mdchangeset.parserOptionsisecmaVersionandsourceTypeonly, and no typed rules are on), so this diff cannot move a verdict on an untouched file. The whole-repopnpm lintis CI's.Acceptance notes
field-read-admission.tsis outside the claim's named file, in the same package and directory. It is the module that holds the gate's refusal envelope and the per-object tiers, which zone 2 item 3 pointed to. The refusal has to sit inside those tiers (next note), so it lands there. None of the three files held by the seat-1 PR is touched.sqlis not a bare object name, which names no attributable field (a cube-level shape this card does not cover).analytics.zod.ts's note that the runtime's expression branches "are left as they are here" stays true of that PR. This PR deletes the gate's half. The raw-SQL emit half lives instrategies/native-sql-strategy.ts, which is held elsewhere, and it is not touched here.Generated by Claude Code