Repository navigation
Commit 37510aa
feat(security): the guest's grants channel, with a share-links-only row scope (ADR-0138 E1, ruling C) (#23321)
Fixes #23224
Clause-②: yes
ADR-0138 **E1, the grants channel** (D3, D4): the guest entry finds D4's
organization, then the `guest` anchor's bindings, and raises the located
refusal where D4 refuses. It also applies the maintainer's **ruling C**
on #23266 (comment 6148183570): the guest's row scope is **share links
only**.
This resumes the released branch (hand-off 6145588579, claim
6146352069). `main` was merged twice. The second merge brought #23056 S8
(PR #23222) and ADR-0138 D2 slice 1. Both `core/security` barrel export
sets are kept.
**Landing:** this is a draft. The seat arms it only after the ruling-C
pins are on its head (they are, at `1d394b81c3`). The one-sentence
ADR-0138 D3 item 6 amendment is a separate Tier H PR from the seat. ⛔ No
`docs/adr/**` path is in this diff.
## What changed
- **`@objectstack/core`**
- `resolveAuthzContext`'s anonymous branch takes a new `guestEntry`
input. On a face that sets it, the branch reads the sets the `guest`
position's definition names, through the same position-bound read every
position uses (`positionBoundSetNames`, factored out of the user path).
It grants them only in the organization `resolveGuestOrganization`
derives.
- If nothing is bound, no read is issued. With no derived organization,
nothing is granted.
- `resolveGuestOrganization` asks ADR-0131 D9's predicate,
`resolveSystemWriteOrganization`. The predicate moved from objectql into
core because core cannot import objectql, and D4 item 2 forbids a copy.
- New module `guest-organization.ts`:
`GuestOrganizationUnresolvedError`, which carries `NOT_IMPLEMENTED` and
`501` from the ADR-0112 catalog, plus
`isGuestOrganizationUnresolvedError`.
- These are new exports. They widen the published index, hence `minor`
and `Clause-②: yes`.
- **`@objectstack/objectql`**
- `tenancy/system-write-organization.ts` re-exports the predicate and
its two types from core as the same bindings. Identity is pinned.
- **`@objectstack/plugin-security`**
- `resolvePermissionSetsForContextUnmemoized` no longer folds the
guest's position into the set request. The guest requests exactly the
sets its envelope carries.
- `resolveOperationPrincipals` calls a new private
`refuseGuestWithoutOrganization`, guarded by one new field. A guest that
reaches a data operation with no organization, on a deployment where the
organization step refuses, gets the located refusal. This happens before
any set resolves, and ahead of the prologue's catch-all, which would
turn it into a `403`. The operator's log names the reason once per
reason.
- **`@objectstack/plugin-sharing`** (ruling C)
- `SharingService.buildReadFilter` answers deny-all for the guest
principal, before the sharing-model branch and its owner-less and
phantom-anchor fall-throughs. The guest reaches rows only through share
links.
- A link is redeemed at its own door: the share-link routes check the
token and `publicSharing.eligibility` and read the one linked record. It
never passes through this filter.
- `externalSharingModel` is still read for no caller (#23268 is its own
card).
- **`@objectstack/runtime`**
- `resolveExecutionContext` passes `guestEntry: true`. This is the one
face that takes the explicit guest entry.
ADR-0087 disposition: **none**. No authorable key, accept set or stored
shape moves.
**Bindings that start granting on the reference apps: 0.**
`app-showcase`, `app-crm` and `app-multi-package` are counted by a
dogfood census with a fixture control. `app-todo` declares no positions
or sets.
**The seat's answers to the forks, as built:**
- the refusal code is `501 NOT_IMPLEMENTED`;
- a walled single-organization deployment keeps D9's refusal (D4 item
2);
- no binding door is built here (#23267).
## The record row's pins and their negative controls
| Pin | Where | Negative control / ablation |
|:--|:--|:--|
| No binding: denied on class 4 | dogfood: `403 PERMISSION_DENIED`,
guest identity, no set | The same door admits once a set is bound (the
next pin). A1 turns the bound pins red. |
| A bound read set: that object only | dogfood: `200` on the bound
object, envelope carries the set and the derived organization, resolved
sets exactly that set; `403` on an unbound object. core unit (15 cases)
| A1 (anonymous branch ignores `guestEntry`): 2 core pins red |
| Baseline, `everyone` and the name fold never reach the guest |
dogfood: a set named like the anchor is `403` for the guest (the admin
reads it: `200`); the guest resolves neither the baseline nor
`everyone`'s set while a member resolves both. plugin-security unit (3
cases with human controls) | A2 (fold restored): 3 unit pins red. A2d
(fold restored, dist rebuilt): 2 door pins red, and the anonymous caller
reads the fold object |
| Multi-organization: the located refusal | dogfood: `501`, `error.code`
`NOT_IMPLEMENTED`, message names the site binding, nothing served.
plugin-security unit: `501` for find and insert, with single-org `403`
and no-organization-object `403` controls | A3 (gate call removed): the
refusal pin red. A4 (grant without D4's organization): 3 core pins red |
| The form doors unchanged | dogfood: single org `201`, row stamped with
the organization; two organizations `500
ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (the form door's own answer,
never the guest refusal) | The class-4 door on the same deployment
answers `501` |
| The guest's row scope for each sharing model (**ruling C**) | dogfood:
`private`, `public_read`, `public_read` with `externalSharingModel:
public_read`, `public_read_write`, a `public_read` parent and its
`controlled_by_parent` child. Each is admitted (`200`) and reads **0
rows**; the admin reads more than 0 of each. plugin-sharing unit:
deny-all for 5 shapes, with a member control and a system control | A5
(the guard kept referenced but never firing): 4 unit pins red, and 5
door pins red (dist rebuilt). `private` stays 0 under A5 by
construction, because the guest was never owner-matched there |
| Share links still serve the guest (ruling C's control) | dogfood: the
guest redeems a link and reads the linked record on an object whose rows
it otherwise reads none of. Eligibility is held at redemption (the
record stops qualifying: `404 INVALID_OR_EXPIRED`) and at the mint (`422
RECORD_NOT_ELIGIBLE`) | n/a |
| The bindings that start granting, counted | dogfood census: 3
reference apps, 0 anchor sets and 0 fold sets | The fixture control
counts its own set named like the anchor |
Ablations are one-shot proofs, run through
`scripts/ablation-replace.mjs`. For every leg the anchor hit was 1 and
the blob changed. Each restore was proven: the blob equals HEAD and `git
diff HEAD` is empty. The door-level legs also ran
`scripts/ablation-dist-preflight.mjs` (`--absent` on the mutation,
present on the restore, rebuilt each way). No ablation file is kept.
- A1 to A4 were re-run on the final head `1d394b81c3`.
- A2d ran at `df4be9b562`.
- A5 ran at `02caf8afc6`. The read filter is unchanged since then.
## Tests (final head `1d394b81c3` unless noted)
| Package | Suite | Typecheck |
|:--|:--|:--|
| core | full suite: 96 files, 2416 passed, at `a001a387ce`; after the
matcher change, the guest-grants file re-run: 15/15 | 0 |
| objectql | full local suite in thirds: 411 files, 8037 passed | 0 (at
`a001a387ce`; objectql unchanged since) |
| plugin-security | full suite in halves: 189 files, 3865 passed, 45
skipped | 0 |
| plugin-sharing | full suite: 42 files, 1032 passed, at `a001a387ce`
(package unchanged since) | 0 |
| runtime | full local suite in fifths: 369 files, 5302 passed, 19
skipped. This includes `resolve-execution-context` and the endpoint
integration tests | 0 (at `a001a387ce`; runtime unchanged since) |
| dogfood | E1 22/22. Anonymous and guest suites on the final dist: 24
files, 394 passed. This covers the guest endpoint, policy, anonymous
door sweep, public form, showcase anonymous-deny, share-link, authz
conformance, probe blind spot and RLS runner suites | 0 |
## Gates
`node scripts/pm/dispatch-gates.mjs --commands` was run on `1d394b81c3`
and derived 85 commands. All 85 ran:
- 84 exit 0 in the battery;
- `check:query-options-erasure` exceeded the battery's 400 s timeout and
was re-run alone, exit 0.
`--ran` reconciliation: 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN.
Four gates went red on the hand-off's files during this round and were
fixed here:
- `check-system-context-census`: the guest refusal's redundant system
read was removed. Both callers return for a system context first.
- `check:engine-double-contract`: the ledger records the plugin-security
unit file's pinned `findOne`, through `--write`, one row.
- `check:slot-lookup`: the dogfood file's engine and security slots are
typed.
- `check:where-matcher`: the core unit double refuses a combinator.
## File surface against the claim
- **The claim's files:** as the hand-off measured them.
- `security-plugin.ts` grew by 4 comment lines, and the guest rule lost
its redundant system read.
- **Grown, named:**
- `packages/plugins/plugin-sharing/src/sharing-service.ts` and its test.
This is ruling C's site (`domain:services`), directed by PM comment
6149308636.
- `scripts/engine-double-contract.pinned.json`, one ledger row that the
gate requires for the hand-off's unit file.
## Acceptance notes
- **Bypass-listed objects:** the sharing service's bypass list
(`sys_user`, `sys_organization`, `sys_member`, `sys_record_share`,
`sys_user_permission_set`) still skips record scoping for every
principal, the guest included. This is the platform bypass, not the OWD
branch, so ruling C does not reach it.
- Whether a set bound to `guest` may name one of these objects at all is
the anchor tier's question.
- The tier is checked where a binding is made: the binding-door card
#23267.
- Noted, not filed.
- **Write side:** the write gates (`checkEdit`, `checkDelete`,
`buildWriteFilter`) still take the OWD branch for a guest. A guest never
reaches them: the guest tier refuses every edit and delete bit
(`describeAnchorForbiddenBits`), so a guest update or delete is refused
at the object level first. Noted.
- **The form door on a multi-organization deployment** answers its own
`500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, as before (D4 item 4 keeps
it). It is pinned unchanged here, not altered.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4d4040c commit 37510aa
15 files changed
Lines changed: 1612 additions & 120 deletions
File tree
- .changeset
- packages
- core/src/security
- objectql/src
- tenancy
- plugins
- plugin-security/src
- plugin-sharing/src
- qa/dogfood/test
- runtime/src/security
- scripts
| 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 | + | |
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
172 | 175 | | |
173 | 176 | | |
174 | 177 | | |
| |||
177 | 180 | | |
178 | 181 | | |
179 | 182 | | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
180 | 205 | | |
181 | 206 | | |
182 | 207 | | |
| |||
0 commit comments