Skip to content

Commit 37510aa

Browse files
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
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/plugin-sharing': patch
6+
'@objectstack/runtime': patch
7+
---
8+
9+
feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4)
10+
11+
ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it).
12+
13+
**The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it.
14+
15+
What changes:
16+
17+
- **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants.
18+
- **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes.
19+
- **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs.
20+
- **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged.
21+
- **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants.
22+
23+
What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`:
24+
25+
- **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner.
26+
- **Nothing bound:** `403 PERMISSION_DENIED`, as before.
27+
- **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once.
28+
29+
Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller.
30+
31+
Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0138 D4 — the refusal an anonymous request meets where it needs an
5+
* organization the deployment cannot resolve for it.
6+
*
7+
* ## The rule
8+
*
9+
* A guest-entry door with no declared organization binding of its own resolves
10+
* the guest's organization on the server, never from anything the client
11+
* sends: when the deployment holds exactly one organization the guest acts in
12+
* it, and otherwise the request is refused, loudly, naming the missing site
13+
* binding (ADR-0138 D5) — never answered by a silent pick. The decision is
14+
* `resolveSystemWriteOrganization`'s (ADR-0131 D9), asked by
15+
* `resolveGuestOrganization` (`resolve-authz-context.ts`), "never a second
16+
* spelling of it" (D4 item 2).
17+
*
18+
* ## Where it is raised, and why not at the entry
19+
*
20+
* The runtime face assembles the guest envelope for EVERY sessionless request
21+
* it serves, before any route is matched — the domains that answer 401 to an
22+
* anonymous caller included. Refusing there would turn every one of those 401s
23+
* into this refusal on a multi-organization deployment, and would reach the
24+
* flow door (D2 class 5), which D4 item 3 leaves untouched: a flow runs under
25+
* its own declared posture. So the entry records nothing it cannot use — no
26+
* organization, no set — and the refusal is raised where the guest's
27+
* organization MATTERS: the CRUD gate the guest's data operation reaches
28+
* (`SecurityPlugin.resolveOperationPrincipals`), before any grant is resolved
29+
* (D3 item 7: where D4 refuses, D3 is never reached).
30+
*
31+
* ## The code
32+
*
33+
* `NOT_IMPLEMENTED` (501), a member of the ADR-0112 standard catalog, as D4
34+
* requires. The request is not malformed and the caller cannot repair it — D4
35+
* forbids taking the organization from anything the client sends, so no
36+
* 4xx-shaped invitation to "supply a precondition" fits — and it is not an
37+
* outage: the deployment lacks a capability that is not built yet, the site
38+
* binding (D5, "built when demand arrives"). `PERMISSION_DENIED` would make the
39+
* refusal indistinguishable from an ungranted guest, which is exactly what the
40+
* located refusal exists to stop.
41+
*
42+
* ## What the caller is told
43+
*
44+
* The message names the missing site binding and the two ways to serve the
45+
* door, and nothing about the deployment: no organization count and no posture
46+
* name reach an anonymous caller (ADR-0138: door-level readings stay private).
47+
*/
48+
49+
/** ADR-0112 standard-catalog member the refusal answers with. */
50+
export const GUEST_ORGANIZATION_UNRESOLVED_CODE = 'NOT_IMPLEMENTED' as const;
51+
52+
/** HTTP status a transport answers the refusal with. */
53+
export const GUEST_ORGANIZATION_UNRESOLVED_STATUS = 501 as const;
54+
55+
/** The caller-facing sentence: the missing site binding, and the remedies. */
56+
export const GUEST_ORGANIZATION_UNRESOLVED_MESSAGE =
57+
'This endpoint serves anonymous callers in the organization the server resolves for them, and this '
58+
+ 'deployment does not resolve one: an anonymous request acts in an organization only on a deployment '
59+
+ 'that holds exactly one (ADR-0138 D4), and no site binding maps a request to an organization of its '
60+
+ 'own yet (ADR-0138 D5). Nothing was read or written. Call this endpoint signed in, or serve it from a '
61+
+ 'single-organization deployment.';
62+
63+
/**
64+
* Why the deployment resolves no organization for the guest — the
65+
* `refuse` reasons of `resolveSystemWriteOrganization`. Server-side only.
66+
*/
67+
export type GuestOrganizationRefusalReason = 'walled-posture' | 'ambiguous-organization' | 'no-organization';
68+
69+
/**
70+
* ADR-0138 D4's located refusal. Identified by {@link isGuestOrganizationUnresolvedError}
71+
* (a `code` and `reason` compare), never `instanceof`, so it survives a package
72+
* boundary where two copies of this module can exist.
73+
*/
74+
export class GuestOrganizationUnresolvedError extends Error {
75+
/** ADR-0112 wire code. */
76+
readonly code = GUEST_ORGANIZATION_UNRESOLVED_CODE;
77+
/** HTTP status a transport should answer — the spelling the HTTP doors read. */
78+
readonly status = GUEST_ORGANIZATION_UNRESOLVED_STATUS;
79+
/** The same number under ADR-0112 D5's spelling. */
80+
readonly httpStatus = GUEST_ORGANIZATION_UNRESOLVED_STATUS;
81+
/** Why no organization resolved — for the server's own log, never the wire. */
82+
readonly reason: GuestOrganizationRefusalReason;
83+
84+
constructor(reason: GuestOrganizationRefusalReason) {
85+
super(GUEST_ORGANIZATION_UNRESOLVED_MESSAGE);
86+
this.name = 'GuestOrganizationUnresolvedError';
87+
this.reason = reason;
88+
}
89+
}
90+
91+
/** True when `err` is ADR-0138 D4's located refusal. */
92+
export function isGuestOrganizationUnresolvedError(err: unknown): err is GuestOrganizationUnresolvedError {
93+
if (typeof err !== 'object' || err === null) return false;
94+
const e = err as { code?: unknown; name?: unknown };
95+
return e.code === GUEST_ORGANIZATION_UNRESOLVED_CODE && e.name === 'GuestOrganizationUnresolvedError';
96+
}

‎packages/core/src/security/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ export {
169169
// security catalog — the `sys_metadata_activation` ledger, never a catalog
170170
// row's `active` column — shared with the explainer and the security plugin.
171171
readDisabledCatalogNames,
172+
// [ADR-0138 D4] The guest entry's organization step, shared with the CRUD
173+
// gate that raises D4's located refusal — one step, two askers.
174+
resolveGuestOrganization,
172175
resolveLocalizationContext,
173176
type ResolvedAuthzContext,
174177
type ResolveAuthzInput,
@@ -177,6 +180,28 @@ export {
177180
type ResolveLocalizationInput,
178181
} from './resolve-authz-context.js';
179182

183+
// [ADR-0131 D9, ADR-0138 D4] The ONE "which organization does a caller with no
184+
// organization of its own act in?" decision. Two askers: the engine's
185+
// system-context insert (`@objectstack/objectql`, which re-exports these same
186+
// bindings) and the guest entry's organization step above.
187+
export {
188+
resolveSystemWriteOrganization,
189+
type SystemWriteOrganizationDecision,
190+
type SystemWriteRefusalReason,
191+
} from './system-write-organization.js';
192+
193+
// [ADR-0138 D4] The located refusal an anonymous request meets where it needs
194+
// an organization the deployment cannot resolve for it — a standard-catalog
195+
// code (`NOT_IMPLEMENTED`), naming the missing site binding (D5).
196+
export {
197+
GuestOrganizationUnresolvedError,
198+
isGuestOrganizationUnresolvedError,
199+
GUEST_ORGANIZATION_UNRESOLVED_CODE,
200+
GUEST_ORGANIZATION_UNRESOLVED_STATUS,
201+
GUEST_ORGANIZATION_UNRESOLVED_MESSAGE,
202+
type GuestOrganizationRefusalReason,
203+
} from './guest-organization.js';
204+
180205
// [#18783] The EFFECTIVE object-permission map — ONE function behind both the
181206
// `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions`
182207
// (the map `current_user.can()` reads). The four folds moved here from

0 commit comments

Comments
 (0)