Repository navigation
Commit ec7c7e0
Fixes #22737
Clause-②: no (narrowing)
Census row 4 of the second-object exposure census (the #22661 card).
Classes, positions and functions only; the fixtures are synthetic.
## What changes
A nested-relation filter condition, a condition on a lookup's related
object, is evaluated on that related object:
`ObjectQL.lowerRelationConditions` reads the TARGET as the caller and
keeps the source rows that point at a match. The data door never asked
the target's declared exposure, so the list and query routes evaluated
such a condition even for a target every data route refuses.
The door's nested-relation walk (`refuseInternalInFilter` over
`relationConditionSites`, in `findData`'s
`assertNoInternalFieldEvaluated`) now asks each condition's TARGET the
spec's one exposure decision before anything of it is judged. The new
method is `refuseUnservedRelationTarget`
(`packages/metadata-protocol/src/protocol.ts`), and it calls
`apiExposureDenialReason`. A target the decision does not serve is
REFUSED with the data door's existing exposure answer:
- `apiEnabled: false` → `404 OBJECT_API_DISABLED`;
- an `apiMethods` whitelist without the operation → `405
OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set.
No new code, no second rule or list. The engine and the filter
compilation are unchanged, so the engine's privileged callers keep their
path.
- **Operation: `list`, not `get`.** `$expand` (#22661) asks `get`
because it turns ids the caller already holds into records. A condition
is a PREDICATE over the target: the engine runs a `find` on the target
with the condition, across the whole object, bounded by a match cap
rather than by the source rows' ids. That is the read the target's own
filtered list performs, and `query` → `list` in
`DATA_ACTION_TO_API_OPERATION`. So a `get`-only target is refused here
while it stays served to `$expand`. A `list`-only target is served.
- **Refused, not withheld.** Dropping a condition would widen the result
set. The precedent at this position is also a refusal: a related object
the member may not read answers `403 PERMISSION_DENIED` there, measured
below and pinned unchanged.
- **Every position the walk covers:** `where`, the `filter` alias, each
`aggregations[i].filter`, and each `expand` entry's own `where` at every
level. The grouped branch is refused before `engine.aggregate`. An
`$expand` entry into an unexposed target is still withheld first
(`servedExpand`), so its filter is never judged.
- **The refusal names the target object** and the condition's position
in its message. The lookup's declared target is already in the source
object's served schema (measured: the source's `/meta` object read
mentions the target for both personas), and the `internal: true` refusal
at this same hop names it too.
- **Provenance.** `@objectstack/metadata-protocol` now stamps both
exposure codes, so its section of the error-code ledger lists them. This
is one pure append in that section: no schema shape change, no existing
entry touched.
## Measurement (real stack: `bootStack`, the real security, engine, SQL
driver and REST layers; administrator and member)
Fixture source object with lookups into targets of each exposure shape.
Each answer was read on the list route and on the query route, once for
a matching related value and once for a non-matching one.
| target shape | before (base `bf515e724d`) | after (this branch) |
|---|---|---|
| `apiEnabled: false` | 200, 1 row on match / 0 on miss | 404
`OBJECT_API_DISABLED` |
| `apiMethods: ['create']` | 200, 1 / 0 | 405
`OBJECT_API_METHOD_NOT_ALLOWED` |
| `apiMethods: []` | 200, 1 / 0 | 405 `OBJECT_API_METHOD_NOT_ALLOWED` |
| `apiMethods: ['get']` | 200, 1 / 0 | 405
`OBJECT_API_METHOD_NOT_ALLOWED` |
| `apiMethods: ['list']` (control) | 200, 1 / 0 | 200, 1 / 0, unchanged
|
| no `enable` block (control) | 200, 1 / 0 | 200, 1 / 0, unchanged |
| exposed, member holds no read (precedent) | member 403
`PERMISSION_DENIED` | unchanged |
Administrator and member answered alike on every row except the
precedent row, where only the member lacks the grant. The #22661 dev's
reading (1–2 rows on a match, 0 on a miss) is confirmed.
## Pins
-
`packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts`
(new, 13 cases). Each refused shape is asserted on `code` + `status`
(and `allowed` for 405), for a member context and a system context, on
the POST `where` and on a `$filter` string under `$or`. Each case also
asserts that `engine.find` / `engine.aggregate` is never reached. It
also covers the grouped branch and an `expand` entry's own filter.
Controls: a `list`-only target, an open target, and a plain id comparand
on the same lookups.
- `packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts`
(#22661's real-stack pin, +5 cases). For both personas: refused
(`status` + `code`) on the list and query routes, matched or not;
control targets still evaluated, 2 rows / 0 rows. Precedent: a member
refused 403 `PERMISSION_DENIED`, unchanged.
- `packages/core/src/security/second-object-read-exposure.pin.test.ts`:
census row 4 moves from `open` to `decided` (operation `list`; decision
`refuseUnservedRelationTarget`; wiring
`this.refuseUnservedRelationTarget(`; pin as above). The type admits
`'get' | 'list'`.
## Verification
- Ablation A, on committed `fe8d208a87` via `ablation-replace.mjs`: the
hop call deleted (anchor 1 → 0, blob changed) → the unit pin goes 10 red
/ 3 green (the three controls). Restored: blob == HEAD, `git diff HEAD`
empty.
- Ablation B: the call replaced by a compiling marker, metadata-protocol
rebuilt (exit 0), `ablation-dist-preflight` marker present in `dist/`.
The dogfood pin goes 2 red (the refused legs, admin and member) / 28
green. Restore leg: blob == HEAD, rebuilt, preflight `--absent` exit 0.
A first B attempt with a non-compiling mutation (an unused private
method) had a build exit 1 while the JS still carried the marker. It was
re-run with the compiling mutation, and these numbers are from that run.
- Ablation C (the pin's D2 leg): the decision call replaced by a
hand-spelled rule → the enumeration pin goes 1 red, naming
`refuseUnservedRelationTarget`.
- Ablation D: the two ledger rows deleted →
`check:error-code-provenance` FAIL, naming both stamp sites. The first D
attempt was refused by `ablation-replace` (replacement text was a
substring of the anchor), so nothing ran. It was re-run in delete mode.
- Packages, at `fe8d208a87`: metadata-protocol vitest 225 files / 28129
passed (19 skipped); metadata-protocol `tsc --noEmit` exit 0 with the
new test in `--listFiles`; core `typecheck` (including
`check:test-typecheck`) exit 0.
- On the merge head `151278b21c` (origin/main `e86530088a` merged,
closure rebuilt): unit pins 28/28, core `repo` project 5 files / 55,
dogfood pin 30/30, dogfood `tsc --noEmit` exit 0 with the file listed,
and spec `check:generated` (14 artifacts) up to date.
- Gates: at `151278b21c` (round 1's merge head), the union of the
dispatch list (82) and `dispatch-gates --commands` on this diff (91) is
95 commands, and all 95 exit 0. Each exit code was captured before any
pipe. `--ran` reconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0
UNRUN; 4 more were run from the dispatch list outside this diff's
derivation. `check:dual-build-cjs-loads` was first `PREREQUISITE NOT
MET` (exit 3) on a tree missing 8 dists; it exited 0 after building
them, and again on this head. Named: `check:error-code-provenance` OK
(red without the ledger rows, ablation D),
`check:error-status-conformance`, `check:error-code-casing`,
`check:dispatcher-error-vocabulary`, the ADR-0087 marker (`not-required
(no-migration-prescription)`), `check-changeset-no-major`,
`check-empty-changeset`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` / `-debt`, `check:published-files`,
`check:authorable-surface` and `check:api-surface`.
- Round 2 at `964aceffd6`: types 27 files / 754 passed plus repo 11;
rest local 272 files / 5239 passed (run in three chunks) plus repo 5
files / 209; dogfood pin 34/34; ablation E (arm deleted, types rebuilt,
preflight `--absent`) reds the types pin 2 / 4, the rest guards 8 / 210
and the dogfood target leg 2 / 34, with controls green; restored blob ==
HEAD; 95 gates exit 0.
- Rounds 3 and 4 merged `main` only, with no rebase and no force-push:
`04cf472669` (after #22766, census rows 5–6) and `606e50f7ef` (after
#22770, census row 7), the final commit. Each conflicted only in the
census pin (`second-object-read-exposure.pin.test.ts`), resolved as the
union: six `decided` reads, and no CALLERS row left `open`. Every other
file's net change is byte-identical to round 2's. At `606e50f7ef`, with
the dogfood closure rebuilt: core `repo` 5 files / 55;
metadata-protocol's two exposure pins 28/28; the dogfood pin 34/34; the
types exposure-arm pin 4/4; rest `repo` 5 files / 209; spec
`check:generated` (14 artifacts) up to date; the same 95 gates exit 0
(`--ran`: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN).
- Lint, narrowed to the 5 changed `.ts` files: `eslint
--no-inline-config --format json` reports 5 files, 0 errors, 0 warnings.
`--print-config` shows 5 active rules per file, none ignored.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so no untouched file's verdict can move.
Repo-wide `pnpm lint` is CI's.
## Acceptance notes
- **Fixed in this PR:** the flat REST body named the route object beside
the exposure code. One arm in `@objectstack/types`
`structuredCodeAnswer` (asked first by `classifyDataError`, so both REST
doors agree) now answers with the error's own `object`, and relays
`allowed` on 405, when the error names an object other than a defined
route object. An error naming the route object, or a door given none
(the analytics dataset route), answers byte for byte as before.
- The MCP stdio data bridge (`stdio-data-bridge.ts`) hands a caller's
filter to the engine directly, after its own addressed-object gate.
Measured in round 2 at the MCP tool surface: the same class as this
card. It is carried by #22777 (a sub-issue of #22661), which waits for
this card; `packages/mcp` is untouched here.
- Deviations: (a) three changesets: a `minor` BREAKING one for
`@objectstack/metadata-protocol` and a `patch` one for
`@objectstack/spec`'s ledger value, the shape the analytics rows'
precedent took. (b) The real-stack legs sit in #22661's dogfood pin,
outside the named file surface, beside their sibling legs. (c) The diff
is 473 changed lines (+457 / -16) across 12 files at the final head:
source +57/-4 (`protocol.ts`) and +23 (`data-error-classification.ts`);
the rest is tests, three changesets, the pin row and the ledger rows.
(d) Round 2 added test-only rows to two `packages/rest` drift guards
that require coverage of every new arm
(`error-response-structured-arm-door-parity.test.ts`,
`error-response-sandbox-arm-message.test.ts`), and a third changeset
(`@objectstack/types` `patch`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b0a9c9 commit ec7c7e0
12 files changed
Lines changed: 457 additions & 16 deletions
File tree
- .changeset
- packages
- core/src/security
- metadata-protocol/src
- qa/dogfood/test
- rest/src
- spec/src/api
- types/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
15 | | - | |
16 | | - | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| |||
137 | 138 | | |
138 | 139 | | |
139 | 140 | | |
140 | | - | |
141 | | - | |
142 | | - | |
143 | | - | |
144 | | - | |
145 | | - | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
146 | 147 | | |
147 | 148 | | |
148 | 149 | | |
| |||
289 | 290 | | |
290 | 291 | | |
291 | 292 | | |
| 293 | + | |
292 | 294 | | |
293 | 295 | | |
294 | 296 | | |
| |||
| 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 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
137 | | - | |
| 137 | + | |
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
| |||
4463 | 4463 | | |
4464 | 4464 | | |
4465 | 4465 | | |
| 4466 | + | |
| 4467 | + | |
| 4468 | + | |
| 4469 | + | |
| 4470 | + | |
| 4471 | + | |
| 4472 | + | |
| 4473 | + | |
| 4474 | + | |
| 4475 | + | |
| 4476 | + | |
4466 | 4477 | | |
4467 | 4478 | | |
4468 | 4479 | | |
| |||
12523 | 12534 | | |
12524 | 12535 | | |
12525 | 12536 | | |
12526 | | - | |
| 12537 | + | |
| 12538 | + | |
| 12539 | + | |
12527 | 12540 | | |
12528 | 12541 | | |
12529 | 12542 | | |
| |||
12566 | 12579 | | |
12567 | 12580 | | |
12568 | 12581 | | |
12569 | | - | |
| 12582 | + | |
| 12583 | + | |
12570 | 12584 | | |
12571 | 12585 | | |
12572 | 12586 | | |
| |||
12581 | 12595 | | |
12582 | 12596 | | |
12583 | 12597 | | |
12584 | | - | |
| 12598 | + | |
| 12599 | + | |
| 12600 | + | |
| 12601 | + | |
| 12602 | + | |
| 12603 | + | |
| 12604 | + | |
| 12605 | + | |
| 12606 | + | |
| 12607 | + | |
| 12608 | + | |
| 12609 | + | |
| 12610 | + | |
| 12611 | + | |
| 12612 | + | |
| 12613 | + | |
| 12614 | + | |
| 12615 | + | |
| 12616 | + | |
| 12617 | + | |
| 12618 | + | |
| 12619 | + | |
| 12620 | + | |
| 12621 | + | |
| 12622 | + | |
| 12623 | + | |
| 12624 | + | |
| 12625 | + | |
| 12626 | + | |
| 12627 | + | |
| 12628 | + | |
| 12629 | + | |
| 12630 | + | |
| 12631 | + | |
| 12632 | + | |
| 12633 | + | |
| 12634 | + | |
12585 | 12635 | | |
| 12636 | + | |
| 12637 | + | |
| 12638 | + | |
12586 | 12639 | | |
12587 | 12640 | | |
12588 | 12641 | | |
| |||
0 commit comments