Repository navigation
Commit e790cfc
feat(security)!: an anonymous caller never owns a record and never sets its owner (ADR-0138 E3) (#23353)
Fixes #23226
Clause-②: no (narrowing) — a guest-supplied owner is refused at the
endpoints and the owner column never holds the guest or the system
principal. That tightens what a guest write may carry and admits nothing
refused today.
ADR-0138 **E3**, D1 ("the guest is a principal, never an owner"). This
lands the guest branch of the owner-anchor stamp in `SecurityPlugin`,
which the record names as D1's enforcement point. Claim `6157234533`,
branch `claude/issue-23226-guest-owner-stamp`. ⛔ Nothing in the record
is re-decided. One measurement departs from D1 outside the stamp. It is
raised to the seat, not decided here (see **Raised, not decided**).
## What changed (`@objectstack/plugin-security` only)
- **Step 3.5, the guest branch.** It applies to the guest envelope
(`principalKind: 'guest'`, no `userId`):
- Nothing is stamped. An absent owner stays unset. An empty-string owner
is also left unset; it used to be stored as an empty string.
- A guest-supplied owner is refused with `403 PERMISSION_DENIED`, on
insert and on update (a transfer, a disown or an echo), **whatever the
guest holds**. The transfer grant is never consulted for a guest, and
the refusal names no grant to acquire.
- Callers with a user id keep the stamp and the forge rule unchanged.
- **One reader of the guest fact.** A module-level `isGuestEnvelope`
holds E1's own predicate (`principalKind === 'guest' && !userId`). The
set resolution (E1, D3) and the organization refusal (E1, D4) now call
it too, instead of keeping two inline copies. This adds no new guest
predicate, and `plugin-sharing`'s module-private `isGuestPrincipal` is
not reused.
- **The `publicFormGrant` branch** is unchanged. It gains a one-line
ADR-0138 D1 note.
- The diff stays clear of the regions #23334 changed (the catalog-echo
registration and the `kernel:ready` pass). #23334 merged first, and
`main` is merged in (`ab5209be3f`), with no conflict.
## Measured on `bbb9757717`, before the change (the PM's H1 to H4)
- **H1. What a guest write stored at the doors E1 opened.**
- **Class-4 endpoint door** (`authRequired: false`, `type:
'object_operation'`):
- With no owner supplied, no owner was stored. The stamp writes `userId`
only when one is present.
- A supplied empty string was stored as an empty string.
- A supplied owner was refused only because the guest lacked the
transfer grant. When the guest's bound set carried the transfer bit, the
forged owner was stored: a real user's id, and also the system
principal's `usr_system` (present as a legacy row).
- The anchor tier refuses that bit at every binding door, so no binding
a deployment can make today reached this.
- **Class-5 flow door:**
- A `runAs: 'user'` flow is refused before its data node reaches the
engine. That is already pinned by
`declarative-endpoint-anonymous-guest.dogfood.test.ts`.
- A `runAs: 'system'` flow bypasses the middleware (see Acceptance
notes).
- **H2. Held.** The guest fact is E1's predicate, factored into one
reader.
- **H3. Held, by order.**
- A `beforeInsert` hook runs inside `engine.insert`'s operation, after
the middleware chain: `triggerHooks('beforeInsert')` sits in the
executor that `executeWithMiddleware` wraps.
- A record-change flow is its own write.
- No middleware registered ahead of the security middleware writes
`owner_id`.
- So any `owner_id` on a guest write's payload at step 3.5 arrived with
that write. "Supplied" and "set by the scenario" are told apart by where
the value came from, never by its content.
- **H4. Held.** `PUBLIC_FORM_SERVER_MANAGED_FIELDS` carries `owner_id`.
The form route's filter and the `publicFormGrant` strip both drop it,
and the form row stays unowned. `packages/spec` is untouched.
- **Assignment rules.** The platform has no first-class assignment-rule
mechanism (zero source hits). An assignment rule is written as a hook or
a record-change flow, the two writers pin 2 exercises.
## Pins, each with a negative control
| Row clause | Pin | Negative control |
|:--|:--|:--|
| 1. A guest insert with nothing setting an owner leaves it unset |
dogfood, class-4 door: the stored owner is null, for an absent owner, a
`null` owner and an empty-string owner. Unit: nothing is stamped, and
empty owners are left null | Control: a signed-in caller's row on the
same object is stamped with its own id. Mutation leg B (the stamp writes
the system principal for a guest) turns pin 1 red |
| 2. An owner set by a hook, flow or assignment rule stands | dogfood: a
code-registered `beforeInsert` hook's owner and a `runAs: 'system'`
record-change flow's owner both stand on the guest's row | Control: the
plain object's guest row, with no writer, is unset, so the owner comes
from the scenario's writer. Under leg B the hook and the flow still win,
and the no-writer control turns red |
| 3. A guest-supplied owner is refused at an endpoint and stripped at
the form doors | dogfood: `403 PERMISSION_DENIED` (code and status), and
no row lands, with a plain set and with a transfer-bit set (the set is
checked against `describeAnchorForbiddenBits` and refused). The form
door strips a submitted owner. Unit: refused for create-only,
transfer-bit and modify-all guests, three forged owners each, batch
rows, and updates | Controls: the same guest and the same grant with no
owner are admitted. A caller with the transfer authority sets the same
owner on the same object, the form object included. Leg A turns the
transfer-bit pin red |
| 4. The owner column never holds the guest or the system principal |
dogfood: a guest naming `usr_system` or `guest` as owner is refused
despite the transfer bit. A census of every guest-written row finds only
null or the scenario's user | Legs A and B both turn it red |
## Ablation
The fix was committed first (`3e44b3d5ee`). Each leg ran through
`scripts/ablation-replace.mjs` in wrap mode, with a `trap` restore. Each
leg rebuilt `plugin-security` and ran
`scripts/ablation-dist-preflight.mjs` before reading any result. No
ablation file is kept.
| Leg | Mutation (anchor hit 1, blob changed) | dist reading | Unit
(guest branch, 5) | Dogfood (13) |
|:--|:--|:--|:--|:--|
| A: remove the guest branch | `const guest =
isGuestEnvelope(opCtx.context);` becomes `const guest = false;` |
`--absent`: marker gone from all 6 built files, tree on the mutate leg |
**4 red**: the empty owner is kept as `''`, and the transfer-bit and
modify-all guests are admitted on insert, batch and update | **4 red**:
the empty owner reads `expected '' to be null`, pin 3 transfer-bit and
pin 4 read `expected 201 to be 403`, and the census finds 8 guest rows
instead of 7 |
| B: the stamp writes the system principal for a guest | `else if
(guest) row.owner_id = 'usr_system';` | present in `index.js` and
`index.mjs` | **3 red** | **5 red**: pin 1 reads `expected 'usr_system'
to be null` (three rows), the pin 2 control and the census fail |
| R: restore | blob `0ce005f6ed` equals HEAD, `git diff HEAD` is empty,
porcelain is empty | `isGuestEnvelope(opCtx.context)` present again, and
leg B's marker `--absent` | 5/5 | 13/13 |
**The direction observed, as it fell.**
- Removing the guest branch does **not** turn pin 1's absent-owner case
red. The stamp's existing `if (userId)` guard already writes nothing for
a caller with no user id, so that case has no guest-specific code to
remove.
- Its teeth are mutation leg B instead, and the member control.
- Leg A turns red the clauses that depend on the branch: the empty
owner, pin 3 with the transfer bit, and pin 4.
## Tests
- **`@objectstack/plugin-security`:**
- Full suite at `ab5209be3f`, in two shards: 189 files, 3883 passed, 45
skipped.
- `typecheck`: 0 errors, and `check:test-typecheck` is OK. `--listFiles`
shows `security-plugin.test.ts` is in `tsconfig.test.json`.
- **`@objectstack/dogfood`:**
- At `ab5209be3f`, on the rebuilt dependency closure: the new file
13/13. With 14 neighbouring suites (guest grants, anonymous endpoint,
endpoint policy, owner anchor and bulk writes, the public form suites,
seed-ownership claim, anonymous door sweep, authz conformance, showcase
anonymous deny, form self-auth): 15 files, 162 passed.
- `typecheck` is 0 errors, and `--listFiles` includes the new file.
- **ESLint, as a proven narrowing:**
- `eslint --no-inline-config --format json` over the 3 changed `.ts`
files: 3 files, 0 errors, 0 warnings, at `ab5209be3f`.
- The population is the root `eslint.config.mjs`'s `**/*.{ts,...}`
block, and no file was reported ignored.
- Invariance: that config enables no type-aware linting (no
`parserOptions.project`, no typed rules, per its own header), so this
diff cannot move any untouched file's verdict.
- The full `pnpm lint` is CI's.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, at `ab5209be3f`: 70 commands, all exit 0.
- `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`,
because 8 unrelated packages had no `dist/`. Those were built, and it
re-ran green.
- `--ran` reconciliation: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN",
"a DERIVED zero — all 70 recorded an exit code and none of them is 3".
- `check:adr-0087-registration`: "✓ check-adr-0087-registration: 1
declared-breaking changeset(s), each carrying an ADR-0087 disposition."
The marker reads `.changeset/23226-guest-owner-stamp.md
[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`, which is the record row's "none: no new
key, and nothing moves".
- Labels: the dispatch names none, and `skip-changeset` does not apply
(the package publishes). Zero label writes.
## Raised, not decided
**The seed-settle claim hands guest-written rows to the platform
admin.**
- What happens: `SecurityPlugin.claimSeedOwnershipOnSettle` runs
`claimSeedOwnership` on every `app:seeded`, which fires on every boot
whose app declares seed data. It re-owns every row of a user-authored
object whose `owner_id` is null (or `usr_system`) to the platform admin.
Its predicate cannot tell a seed row from a guest-written or form-door
row.
- Measured on a booted stack (a scratch probe, not committed): a guest
row and a form-door row, both unowned, became the platform admin's after
one `app:seeded`. The log line read `handed 2 seeded record(s) to
platform admin`.
- The record says otherwise:
- D1 item 3: "The platform stamps nothing on a guest insert and declares
no organization-level default owner".
- D1 item 5: "a guest-written row keeps no owner, exactly as the public
form doors' intake does today". The form doors' rows are claimed the
same way today.
- This sits outside the stamp, the record's named enforcement point, and
nothing in this diff depends on how it is answered. The question and its
options are in the `os-dev-report` comment on #23226.
## Acceptance notes
- **The class-5 flow door under `runAs: 'system'`.** Until D2b's publish
refusal lands (card E2), such a flow writes as the system principal.
That bypasses the middleware, this branch included. The row's owner is
whatever the flow's nodes set, and a node can map a request input into
`owner_id`. Carrier: E2's card.
- **The ADR anchor registry.**
`scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json`
lists ADR-0055, ADR-0106 and ADR-0123. ADR-0138 is cited in the code at
each site but is not added to the registry, which is outside the claim's
file surface. Carrier: none.
- **File surface, against the claim.** All edits are in the claimed
`security-plugin.ts`: step 3.5, E1's `:6486` read and its D4 refusal
read (now through the one reader), and a comment in the
`publicFormGrant` branch. The rest is its tests (a unit block in
`security-plugin.test.ts`, and the dogfood file) and one changeset.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 228357a commit e790cfc
4 files changed
Lines changed: 622 additions & 2 deletions
File tree
- .changeset
- packages
- plugins/plugin-security/src
- qa/dogfood/test
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
592 | 592 | | |
593 | 593 | | |
594 | 594 | | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
595 | 743 | | |
596 | 744 | | |
597 | 745 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
167 | 179 | | |
168 | 180 | | |
169 | 181 | | |
| |||
2406 | 2418 | | |
2407 | 2419 | | |
2408 | 2420 | | |
| 2421 | + | |
| 2422 | + | |
| 2423 | + | |
2409 | 2424 | | |
2410 | 2425 | | |
2411 | 2426 | | |
| |||
3343 | 3358 | | |
3344 | 3359 | | |
3345 | 3360 | | |
| 3361 | + | |
| 3362 | + | |
| 3363 | + | |
| 3364 | + | |
| 3365 | + | |
| 3366 | + | |
| 3367 | + | |
| 3368 | + | |
| 3369 | + | |
| 3370 | + | |
| 3371 | + | |
| 3372 | + | |
| 3373 | + | |
| 3374 | + | |
| 3375 | + | |
3346 | 3376 | | |
3347 | 3377 | | |
3348 | 3378 | | |
| |||
3396 | 3426 | | |
3397 | 3427 | | |
3398 | 3428 | | |
| 3429 | + | |
| 3430 | + | |
| 3431 | + | |
| 3432 | + | |
| 3433 | + | |
| 3434 | + | |
| 3435 | + | |
| 3436 | + | |
| 3437 | + | |
| 3438 | + | |
| 3439 | + | |
| 3440 | + | |
3399 | 3441 | | |
3400 | 3442 | | |
3401 | 3443 | | |
| |||
3417 | 3459 | | |
3418 | 3460 | | |
3419 | 3461 | | |
| 3462 | + | |
| 3463 | + | |
| 3464 | + | |
| 3465 | + | |
| 3466 | + | |
| 3467 | + | |
3420 | 3468 | | |
3421 | 3469 | | |
3422 | 3470 | | |
| |||
3434 | 3482 | | |
3435 | 3483 | | |
3436 | 3484 | | |
| 3485 | + | |
| 3486 | + | |
| 3487 | + | |
3437 | 3488 | | |
3438 | 3489 | | |
3439 | 3490 | | |
| |||
6511 | 6562 | | |
6512 | 6563 | | |
6513 | 6564 | | |
6514 | | - | |
| 6565 | + | |
6515 | 6566 | | |
6516 | 6567 | | |
6517 | 6568 | | |
| |||
8794 | 8845 | | |
8795 | 8846 | | |
8796 | 8847 | | |
8797 | | - | |
| 8848 | + | |
8798 | 8849 | | |
8799 | 8850 | | |
8800 | 8851 | | |
| |||
0 commit comments