Repository navigation
Commit e6dc7a2
fix(metadata-core,rest,runtime): judge an objectOverride action param against the object it names (#21904)
Fixes #21884
Clause-②: yes (widening)
## What a user saw
A `delegated_admin` may invite members: the invite door answers 200 for
that principal. But `GET /meta/object/sys_user` served that principal no
`invite_user` action, so the console withheld an action the server
admits. The action's `role` param is `{ field: 'role', objectOverride:
'sys_member' }`, so it names `sys_member.role`. The ADR-0106 mask read
every param's `field` as a field of the served object. A caller denied
`sys_user.role` therefore lost the whole action.
The mechanism, measured at `607463d736`: `presentationEntry` in
`packages/metadata-core/src/object-schema-fls-references.ts` tested
every non-list key of an action with `mentionsDenied({ [key]: inner },
denied, 'skip', 'classified')` (line 383), and `denied` is the served
object's denied set. A unit repro against the base build (`role` denied
on `sys_user`) served `['other']` and dropped `invite_user`. The base
census below shows the same thing on a real showcase boot.
## What changed
**The rule (`@objectstack/metadata-core`,
`object-schema-fls-references.ts`).** An action's `params` are now read
one param at a time (`actionParamReadsDenied`). A param whose
`objectOverride` names another object reads that object's field. Its
`field` is judged against the caller's readable set on that object, and
it is not a reference to the served object's fields. The action is still
dropped when the field is not readable there. It is also dropped when
that object's readable set cannot be determined. The override's value is
an object name, so it is no longer tested as a field token. Everything
else on the param is still read against the served object. There is no
special case for `invite_user`: the rule covers every authored param
with `objectOverride`.
**Where the other object's readable set comes from (H3).** The posture
is still decided once per caller and object, before the fetch (ADR-0106
D3). Only the fetched document says which other objects its params name,
so the related half runs after the fetch:
- `resolveObjectSchemaMaskPosture` now puts `relate` on a `project`
posture. `relate` asks the posture's own question (same caller, same
security service, same D7 preference for `getMetadataReadableFields`)
about another object.
- `relateObjectSchemaMaskPosture(posture, ...documents)` fills `related`
for the objects those documents' params name. It does nothing for any
other posture or for a document with no such param. It asks each object
once, and it never throws.
- `applyObjectSchemaMask` passes `related` into the reference mask.
Every related read it withholds goes into the fingerprint as
`object.field`. Two callers denied the same fields on the served object
but different fields on the other object therefore never share a
validator (D3's 304 cohorts). An unrestricted caller's ETag is
byte-identical to before.
I chose this over a second posture argument at every exit for one
reason: the posture already reaches every projection site, and the
masker closure that resolved it does not. With `relate` on the posture,
each exit adds one awaited call between its fetch and its projection. No
exit had to add a port or a request field.
**Every exit relates its posture (`@objectstack/rest`,
`@objectstack/runtime`).** The issue placed the fix at
`presentationEntry`, with the runtime dispatcher's `maskObjectSchema` as
the possible exit. Measured, the projections that serve actions live in
two packages. In `@objectstack/rest` they are the shared item chain,
layered chain and list chain (`meta-item-read-gate.ts`) and
`RestServer`'s cached read and published read (`rest-server.ts`). The
runtime dispatcher reaches the shared chains through
`projectMetaObjectSchema` plus its own `maskObjectSchema`. ADR-0106 D5
requires all of them to mask alike. So each one now relates its posture
right after the fetch, which is the narrowest correct form:
`packages/rest` is the real home of most exits. The `/meta` diff route
masks only `{ fields }` and has no actions, so it needs no relate step.
**The shared contract (`@objectstack/metadata-core/testing`).**
`FLS_CONTRACT_OBJECT` gains two actions whose params read `contact`
fields through `objectOverride`, and the retention facts require the
readable one to be served. An exit that skips the relate step withholds
it (fail closed) and fails the contract by exit name. This was measured:
see reverse verification below.
## Decisions
- **H4: the param's `name`.** Under `objectOverride`, a `name` that
repeats `field` is read as that field, so it is judged on the other
object. An explicit `name` that differs from `field` is a request-body
key whose owner nothing here can verify. It keeps the existing reading,
as a reference to the served object, which can over-mask but never leak.
With `defaultFromRow`, the param also seeds `field` from the served
object's row (the spec's "key = the resolved field name"). That is a
second read of the served object, so `field` is judged there too. All
three cases are pinned.
- **H5: fail closed.** If the security service has no answer for the
other object, throws for it, or the object does not exist, the action is
dropped. A `project` posture that nobody related (hand-built, or an exit
that skipped the step) relates nothing, so its `objectOverride` actions
are dropped too. A related throw withholds only the actions that read
that object, with a `warn` naming it. The served object's own D6 tiers
are unchanged. Exempt callers (platform admin, `isSystem`) get
passthrough and the service is never asked about the related object.
That is pinned.
## Census (H2), measured on a real showcase boot
I added a scratch probe under `packages/qa/dogfood/test/` (deleted
afterwards, not committed). It read every object schema the showcase
serves (78 objects) through the by-name read and the list read, as five
principals in one organization: the seeded platform admin, an `owner`
who is not a platform admin, an `admin`, a `delegated_admin` and a
`member`. I ran it once on head, and once with all six touched source
files restored to the base blobs and those packages rebuilt. The restore
was proven by blob equality with HEAD and an empty `git diff HEAD`.
The workspace has two authored params with `objectOverride`:
`sys_user.invite_user`'s `role` (on `sys_member`) and
`sys_member.invite_user`'s `email` (on `sys_invitation`).
`sys-member.object.ts` has two hits, but the other one is a comment. The
only other hit is the `packages/lint` test fixture, which this mask
never reads.
| principal | `sys_user.invite_user`, base to head |
`sys_member.invite_user`, base to head |
|:--|:--|:--|
| platform admin | served, served | served, served |
| owner | served, served | served, served |
| admin | served, served | served, served |
| delegated_admin | **dropped, served** | served, served |
| member | **dropped, served** | served, served |
The by-name read and the list read agree in every cell. Across all 78
objects × 5 principals × 2 reads, the only served/dropped verdicts that
moved are the two in bold. No read answered anything but 200 at base or
head. On head, the `delegated_admin` and the `member` are both not
served `sys_user.role`, and both are served `sys_member.role`.
## Why the member is still not offered it
The member is **not** denied `sys_member.role`, so it is now served
`sys_user.invite_user` in the metadata. It is not offered the action
because of the reach gate from #21883: `requiresMembershipReach:
'invite_member'` lowers to a `visible` predicate over
`current_user.positions`, and that predicate excludes the member grade.
The dogfood case says this in as many words. It asserts that both grades
are denied `sys_user.role`, served `sys_member.role` and served the
action, that the delegated_admin is offered it, and that the member's
served predicate evaluates false.
## Exported surface (measured on the built declarations)
I diffed `packages/metadata-core/dist/index.d.ts` (and `index.d.cts`)
built at base `607463d736` against head:
- added: `relateObjectSchemaMaskPosture(posture, ...documents)`;
- added: two optional members on the `project` member of
`ObjectSchemaMaskPosture`, `related` (a map from object name to its
readable field set, or undefined) and `relate` (a function from object
name to a promise of that set);
- `dist/testing.d.ts`: the `FLS_CONTRACT_OBJECT` literal type gains the
two actions;
- nothing removed, renamed or narrowed. The rest of the diff is docblock
text.
So the claim's `Clause-②: no` becomes `yes (widening)`, and
`@objectstack/metadata-core` takes a `minor` changeset.
`@objectstack/rest` and `@objectstack/runtime` take `patch`: their
exported signatures are unchanged (`projectMetaObjectSchema` keeps its
signature).
## Tests
Final head `e5e2792cf1`, which merges `origin/main` at `1e18a0735c`:
- `pnpm --filter @objectstack/metadata-core test`: 18 files, 397 passed.
- dogfood, `--project isolated`:
`org-admin-affordance-reach.dogfood.test.ts` and
`delegated-admin-invite.dogfood.test.ts`, 2 files, 17 passed.
- The ADR-0106 contract suites at every exit:
`packages/rest/src/meta-object-fls.test.ts` plus the two capability-gate
suites that read the fixture, 3 files, 123 passed;
`packages/runtime/src/domains/meta-object-fls.test.ts`, 85 passed.
- `typecheck` for metadata-core and dogfood: exit 0.
At `0f9ce970cd`, the previous merge of `origin/main`:
- full `@objectstack/rest` (both projects): 265 files, 5075 passed, 327
skipped;
- full `@objectstack/runtime` (both projects): 330 files, 5390 passed,
19 skipped;
- `typecheck` for rest and runtime: exit 0.
Between those two heads, this branch changed two test titles, and
`origin/main` brought a `platform-objects` action retirement and spec
test-title text. Neither touches rest or runtime, so the full suites
were not rerun (AGENTS.md, Multi-agent discipline §10). CI runs them.
New unit pins, in `object-schema-fls-references.test.ts` under "an
action param under `objectOverride` is judged against the object it
names":
- triage's three:
- a delegated_admin-shaped caller is served `invite_user`;
- an action whose param names a denied field of the served object is
still dropped;
- an `objectOverride` param on a field denied on the other object still
drops the action, even when nothing of the served object is denied;
- fail closed: undetermined, throwing, unknown object, and an unrelated
posture;
- exempt callers are untouched;
- the `name` and `defaultFromRow` readings;
- fingerprint cohorts;
- relate asks each object once.
The dogfood: `org-admin-affordance-reach.dogfood.test.ts` pinned the
defect itself as `MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user']`.
That pin is now "every site is served to every grade". The new case, "a
delegated_admin is offered Invite User on sys_user; a plain member is
not — by the reach gate, not the field mask", is the one dogfood test
for this card. I edited the existing file rather than adding a second
showcase boot.
## Reverse verification (one-off, on committed HEAD `af06da75ac`)
- **The rule.** Through `scripts/ablation-replace.mjs`, I set
`presentationEntry`'s key reading back to the base reading (`const read
= readsAsThisObject(key);`, which reads `params` as the base did).
Anchor 1 to 0, blob `d7455eadbd55` to `5b00498c2f07`. Result: 4 failed,
73 passed. Red: the delegated_admin pin; the other-object-denied pin
(through its served-object-whole half, where the base serves the
action); fail-closed; and the `name`/`defaultFromRow` pin. Green, as
expected: the "denied field of THIS object" pin, the exempt pin, the
fingerprint pin and the relate pin. The restore was proven by `blob ==
HEAD (d7455ea)` and an empty `git diff HEAD`.
- **An exit that forgets to relate.** I removed the relate step from the
shared item chain (`createMetaItemAnswer`) and ran
`packages/rest/src/meta-object-fls.test.ts`. Result: 4 failed, 91
passed. The four were `restricted-caller/field-vanishes-whole`,
`restricted-caller/required-permissions-cause`,
`unrestricted-caller/byte-identical` and `guest-fallback/D7`, each
failing under the exit "GET /meta/object/:name — uncached branch" with
"the mask over-reached". No other exit failed. The restore was proven by
`blob == HEAD (b9e94d4)` and an empty `git diff HEAD`.
## Gates (at `e5e2792cf1`)
- **Derived families.** After the second merge, `node
scripts/pm/dispatch-gates.mjs --commands` derives the same 68 families.
All 68 exit 0, and `--ran` reconciles them: 68 run, 0 NOT MEASURED, 0
unrun, and every family carries a recorded exit code.
- **`check:dual-build-cjs-loads`.** At `0f9ce970cd` it first answered
`PREREQUISITE NOT MET`, because 8 packages outside the dogfood closure
had no `dist/`. That was a run that measured nothing, not a red. After
`turbo run build` over `./packages/*` and `./packages/*/*`, it reported
106 entry points across 66 packages load.
- **The artifact-roster block.** It is printed outside the derived
total, unchanged after the merge, and has 53 commands. 50 exit 0. Three
need a PR's context and answered NOT WIRED or NOT MEASURED locally:
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`. `check-partof-closing-keyword` passes on
this body when given it as `PR_BODY`. The other two run against this PR
once it exists, and their results are in the dev report.
- **The four symbol-anchor sweeps.** `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and
`check:adr-anchors` all exit 0.
- **ESLint, narrowed.** `pnpm exec eslint --no-inline-config --format
json` over the 9 touched `.ts` files reports 9 files, 0 errors, 0
warnings. All 9 are in the population `eslint.config.mjs` declares
(`packages/**/*.{ts,tsx,mts,cts}`). The config never enables type-aware
linting (no `parserOptions.project`, no `projectService`), so this diff
cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is
CI's.
## Acceptance notes
- **Cost.** A masked read of a document with an `objectOverride` param
costs one more security-service read per object those params name. Today
that means two documents, `sys_user` and `sys_member`, one related
object each. It applies to every `project` posture, including a caller
who is denied nothing on the served object, because that caller can
still be denied the field on the other object.
- **What the contract cannot express.** The contract's security double
answers the same set for every object, so it cannot express "denied
here, readable there", which is this card's own case. The unit pins and
the dogfood hold that case. The contract holds that every exit relates.
- **Lane.** The edits to `packages/rest/src/meta-item-read-gate.ts` and
`packages/rest/src/rest-server.ts` are outside the declared lane. They
are where most of the exits are (see above). `sys-user.object.ts` is
untouched: the declaration was right and the mask was wrong.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent cab6396 commit e6dc7a2
10 files changed
Lines changed: 522 additions & 57 deletions
File tree
- .changeset
- packages
- metadata-core/src
- qa/dogfood/test
- rest/src
- runtime/src/domains
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
38 | 40 | | |
39 | 41 | | |
40 | 42 | | |
| |||
100 | 102 | | |
101 | 103 | | |
102 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
103 | 111 | | |
104 | 112 | | |
105 | 113 | | |
| |||
195 | 203 | | |
196 | 204 | | |
197 | 205 | | |
198 | | - | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
199 | 210 | | |
200 | 211 | | |
201 | 212 | | |
| |||
229 | 240 | | |
230 | 241 | | |
231 | 242 | | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
232 | 247 | | |
233 | 248 | | |
234 | 249 | | |
| |||
241 | 256 | | |
242 | 257 | | |
243 | 258 | | |
244 | | - | |
| 259 | + | |
245 | 260 | | |
246 | 261 | | |
247 | 262 | | |
| |||
Lines changed: 152 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
15 | 20 | | |
16 | 21 | | |
17 | 22 | | |
| |||
458 | 463 | | |
459 | 464 | | |
460 | 465 | | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
461 | 606 | | |
462 | 607 | | |
463 | 608 | | |
| |||
509 | 654 | | |
510 | 655 | | |
511 | 656 | | |
512 | | - | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
513 | 663 | | |
514 | 664 | | |
515 | 665 | | |
| |||
0 commit comments