Repository navigation
Commit ae1e950
Fixes #20965
Clause-②: no
⚠️ Security family, same disclosure discipline as the parent card: this
body carries no request body, header, field spelling or returned value.
Classes and positions only.
## What changes
The analytics field-level read gate no longer stands down on a cube
member that names no field. A member whose `sql` resolves 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 correction `5921856508` as 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 whose `sql` is 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`: `assertNamedFieldsReadable` refuses 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's `sql`, because echoing it would hand the cube
author's text to a caller who was refused for not being judged able to
read it.
- Changeset: `@objectstack/service-analytics` patch.
## Premise check (zone 2, measured at base `c6954d6d`)
1. **The stand-down's location.** It was `fieldsOfColumnSql` (its doc
arm at `:421` at base; the card said about `:409`, the claim said
`:416`). That is the field-level read gate's resolver.
`resolveMemberSource` is not part of that gate. It belongs to the two
source-field EXISTENCE gates, `assertDimensionFields` and
`assertWhereFields` (`INVALID_FIELD` / `400`, "does the object have this
column"). Its `source: null` on an expression means "no column to check
for existence", and it is left as it is. Those gates run first, in
`ensureCube`, and stand down on an expression. The refusal then comes
from the field gate in `callCtx`.
2. **How a stored cube reaches the gate.** There is no metadata read
path into the analytics registry. `CubeRegistry` has two writers:
configuration cubes (`AnalyticsServiceConfig.cubes`, fed by
`AnalyticsServicePlugin({ cubes })`, which the CLI's `analyticsCubes`
capability arg builds) and compiled datasets. `register` never 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 that `CubeSchema`
refuses exactly its two expression members, plus one member with no
`sql` string, and nothing else. A plugin-level pin passes the same cube
through `AnalyticsServicePlugin({ cubes })` with the security bridge.
3. **"The engine's 403 shape".** It is reused from
`field-read-admission.ts`: `PERMISSION_DENIED`, pinned against the
standard catalog, with status `403` and the `object` property. No new
code.
4. **Consumers of `resolveMemberSource`** at base, by `git grep`:
`assertDimensionFields` (one site) and `assertWhereFields` (two sites),
plus doc mentions in `where-source-field-gate.test.ts`. None consumes
the field gate's verdict. The field gate's collector `namedQueryFields`
has 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. `assertFieldsReadable` now
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 `sql` as
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 refused `PERMISSION_DENIED` / `403` on both strategy paths, with
nothing executed. With no reader, the behaviour is unchanged (see
Acceptance notes).
## Pins
- **Deleted**: the expression half of
`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".
- **Added** (`field-read-admission-gate.test.ts`, block "a member that
names no field is refused, never stood down"):
- the fixture is written around the parse (`CubeSchema` refuses exactly
the members the gate refuses);
- refusal, for each strategy path and on both doors (cube read and SQL
echo): an aggregated expression measure, a grouped expression dimension,
a filter member, an order key, a declared member with no `sql` string,
and a member the query names itself that is not a column reference. Each
asserts `code`, `status`, `object` and `member`, that nothing executed,
and that the message carries neither of the author's expression texts;
- no grant makes it judgeable: a reader answering every field still
refuses it;
- it is refused ahead of a hidden field on the same object;
- every field member is judged as before, on the same cube;
- `'*'` still counts (the control): a count beside a field member is
served, and a count alone is served with the reader never asked;
- plugin bridge: a configured cube's expression member is refused
through the security service's reader.
- The existing field-gate table (14 refusal cases × 2 strategies × 2
doors) and the rest of the file are unchanged and green.
## Ablations (each from committed head `f6f10470`, through
`scripts/ablation-replace.mjs`: anchor hit once, blob changed, restored
blob equal to HEAD, `git diff HEAD` empty)
The test file imports the service by relative path
(`../analytics-service.js`), so the ablated source is what runs and no
`dist/` is involved.
| leg | mutation | predicted | measured |
|---|---|---|---|
| A1 | the stand-down put back: `fieldsOfColumnSql` answers an empty
list for a non-column `sql` | 15 red / 55 green | 15 failed / 55 passed.
Every refusal row except the no-`sql` one, no-grant, ahead-of, and the
plugin pin. Failure shape: `expected null to match object { code:
'PERMISSION_DENIED', …(3) }`, and `promise resolved … instead of
rejecting` |
| A2 | a declared member with a non-string `sql` names nothing | 2 red |
2 failed / 68 passed. The no-`sql` row, where the strategy then crashed
with a `TypeError` |
| A3 | the gate's refusal statement removed (`field-read-admission.ts`)
| 17 red | 17 failed / 53 passed |
| A4 | the `'*'` arm removed, so the wildcard reads as an expression |
the control turns red, along with every count under an answering reader
| 47 failed / 23 passed, both `'*'` control cases among them |
Restored 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
`--listFiles` includes the edited test file.
- Route level, over the real security plugin, engine and SQL driver:
`@objectstack/rest` `analytics-field-permission-gate`,
`analytics-masked-field-gate`, `analytics-relationship-path-admission`
and `analytics-hop-object-reference` gave 4 files, 60 passed, against
the rebuilt `service-analytics` dist.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 62 commands. All 62 were
run, and `--ran` reconciled 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's `dist/`
and this worktree built only the analytics closure. It is NOT MEASURED
and declared to CI.
- Lint, a declared narrowing: `eslint --no-inline-config --format json`
over the 3 changed lintable files gave 3 files, 0 errors and 0 warnings
at `f6f10470`.
- The population comes from `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block; the fourth changed file is
the `.md` changeset.
- The count comes from the JSON output.
- Invariance: the config never enables type-aware linting (every
`parserOptions` is `ecmaVersion` and `sourceType` only, and no typed
rules are on), so this diff cannot move a verdict on an untouched file.
The whole-repo `pnpm lint` is CI's.
## Acceptance notes
- **File surface, declared.** `field-read-admission.ts` is 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.
- **The tier the refusal respects.** The refusal fires where the gate
judges the member's object, that is, where a reader gives a field answer
for it. With no security service (the plugin's reader answers "no
answer") or for an object the reader cannot resolve, no member of that
object is judged, field or not. That is the gate's existing tier.
Refusing there would put a permission refusal on a deployment that has
no permissions. Raised to the seat as an open question in the report; no
pin holds either reading for that tier.
- **Caller-named members.** The rule covers every member the gate sees,
so a member the query names itself that is not a column reference is now
refused too, wherever the reader answers. At base it reached the
native-SQL statement as written. The remaining no-reader case of that
shape is reported to the seat, not fixed here.
- **Left as they are:** the existence gates' stand-down (above); and a
cube whose own base `sql` is not a bare object name, which names no
attributable field (a cube-level shape this card does not cover).
- **Spec doc.** `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 in
`strategies/native-sql-strategy.ts`, which is held elsewhere, and it is
not touched here.
- **Landing.** A security-family change, left draft for the seat's
review.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent df1feae commit ae1e950
4 files changed
Lines changed: 271 additions & 23 deletions
File tree
- .changeset
- packages/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 | + | |
Lines changed: 122 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
18 | 25 | | |
19 | 26 | | |
20 | 27 | | |
21 | 28 | | |
22 | | - | |
| 29 | + | |
23 | 30 | | |
24 | 31 | | |
25 | 32 | | |
| |||
62 | 69 | | |
63 | 70 | | |
64 | 71 | | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
65 | 82 | | |
66 | 83 | | |
67 | 84 | | |
| |||
105 | 122 | | |
106 | 123 | | |
107 | 124 | | |
108 | | - | |
| 125 | + | |
109 | 126 | | |
110 | 127 | | |
111 | 128 | | |
| |||
201 | 218 | | |
202 | 219 | | |
203 | 220 | | |
204 | | - | |
205 | | - | |
206 | | - | |
207 | | - | |
208 | | - | |
| 221 | + | |
209 | 222 | | |
210 | 223 | | |
211 | 224 | | |
| |||
276 | 289 | | |
277 | 290 | | |
278 | 291 | | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
279 | 380 | | |
280 | 381 | | |
281 | 382 | | |
| |||
298 | 399 | | |
299 | 400 | | |
300 | 401 | | |
301 | | - | |
| 402 | + | |
302 | 403 | | |
303 | 404 | | |
304 | 405 | | |
| |||
312 | 413 | | |
313 | 414 | | |
314 | 415 | | |
315 | | - | |
| 416 | + | |
316 | 417 | | |
317 | 418 | | |
318 | 419 | | |
| |||
344 | 445 | | |
345 | 446 | | |
346 | 447 | | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
347 | 460 | | |
Lines changed: 32 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
| 54 | + | |
54 | 55 | | |
55 | 56 | | |
56 | 57 | | |
| |||
418 | 419 | | |
419 | 420 | | |
420 | 421 | | |
421 | | - | |
422 | | - | |
423 | | - | |
424 | | - | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
425 | 430 | | |
426 | 431 | | |
427 | 432 | | |
428 | 433 | | |
429 | 434 | | |
430 | 435 | | |
431 | 436 | | |
432 | | - | |
| 437 | + | |
433 | 438 | | |
| 439 | + | |
434 | 440 | | |
435 | | - | |
| 441 | + | |
436 | 442 | | |
437 | 443 | | |
438 | 444 | | |
| |||
466 | 472 | | |
467 | 473 | | |
468 | 474 | | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
469 | 482 | | |
470 | 483 | | |
471 | 484 | | |
| |||
476 | 489 | | |
477 | 490 | | |
478 | 491 | | |
479 | | - | |
| 492 | + | |
480 | 493 | | |
481 | 494 | | |
482 | | - | |
| 495 | + | |
483 | 496 | | |
484 | 497 | | |
485 | 498 | | |
486 | | - | |
487 | | - | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
488 | 506 | | |
489 | 507 | | |
490 | 508 | | |
| |||
1779 | 1797 | | |
1780 | 1798 | | |
1781 | 1799 | | |
| 1800 | + | |
| 1801 | + | |
| 1802 | + | |
| 1803 | + | |
1782 | 1804 | | |
1783 | 1805 | | |
1784 | 1806 | | |
| |||
0 commit comments