Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/23224-guest-grants-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/core': minor
'@objectstack/objectql': patch
'@objectstack/plugin-security': patch
'@objectstack/plugin-sharing': patch
'@objectstack/runtime': patch
---

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)

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).

**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.

What changes:

- **`@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.
- **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes.
- **`@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.
- **`@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.
- **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants.

What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`:

- **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.
- **Nothing bound:** `403 PERMISSION_DENIED`, as before.
- **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.

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.

Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move.
96 changes: 96 additions & 0 deletions packages/core/src/security/guest-organization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0138 D4 — the refusal an anonymous request meets where it needs an
* organization the deployment cannot resolve for it.
*
* ## The rule
*
* A guest-entry door with no declared organization binding of its own resolves
* the guest's organization on the server, never from anything the client
* sends: when the deployment holds exactly one organization the guest acts in
* it, and otherwise the request is refused, loudly, naming the missing site
* binding (ADR-0138 D5) — never answered by a silent pick. The decision is
* `resolveSystemWriteOrganization`'s (ADR-0131 D9), asked by
* `resolveGuestOrganization` (`resolve-authz-context.ts`), "never a second
* spelling of it" (D4 item 2).
*
* ## Where it is raised, and why not at the entry
*
* The runtime face assembles the guest envelope for EVERY sessionless request
* it serves, before any route is matched — the domains that answer 401 to an
* anonymous caller included. Refusing there would turn every one of those 401s
* into this refusal on a multi-organization deployment, and would reach the
* flow door (D2 class 5), which D4 item 3 leaves untouched: a flow runs under
* its own declared posture. So the entry records nothing it cannot use — no
* organization, no set — and the refusal is raised where the guest's
* organization MATTERS: the CRUD gate the guest's data operation reaches
* (`SecurityPlugin.resolveOperationPrincipals`), before any grant is resolved
* (D3 item 7: where D4 refuses, D3 is never reached).
*
* ## The code
*
* `NOT_IMPLEMENTED` (501), a member of the ADR-0112 standard catalog, as D4
* requires. The request is not malformed and the caller cannot repair it — D4
* forbids taking the organization from anything the client sends, so no
* 4xx-shaped invitation to "supply a precondition" fits — and it is not an
* outage: the deployment lacks a capability that is not built yet, the site
* binding (D5, "built when demand arrives"). `PERMISSION_DENIED` would make the
* refusal indistinguishable from an ungranted guest, which is exactly what the
* located refusal exists to stop.
*
* ## What the caller is told
*
* The message names the missing site binding and the two ways to serve the
* door, and nothing about the deployment: no organization count and no posture
* name reach an anonymous caller (ADR-0138: door-level readings stay private).
*/

/** ADR-0112 standard-catalog member the refusal answers with. */
export const GUEST_ORGANIZATION_UNRESOLVED_CODE = 'NOT_IMPLEMENTED' as const;

/** HTTP status a transport answers the refusal with. */
export const GUEST_ORGANIZATION_UNRESOLVED_STATUS = 501 as const;

/** The caller-facing sentence: the missing site binding, and the remedies. */
export const GUEST_ORGANIZATION_UNRESOLVED_MESSAGE =
'This endpoint serves anonymous callers in the organization the server resolves for them, and this '
+ 'deployment does not resolve one: an anonymous request acts in an organization only on a deployment '
+ 'that holds exactly one (ADR-0138 D4), and no site binding maps a request to an organization of its '
+ 'own yet (ADR-0138 D5). Nothing was read or written. Call this endpoint signed in, or serve it from a '
+ 'single-organization deployment.';

/**
* Why the deployment resolves no organization for the guest — the
* `refuse` reasons of `resolveSystemWriteOrganization`. Server-side only.
*/
export type GuestOrganizationRefusalReason = 'walled-posture' | 'ambiguous-organization' | 'no-organization';

/**
* ADR-0138 D4's located refusal. Identified by {@link isGuestOrganizationUnresolvedError}
* (a `code` and `reason` compare), never `instanceof`, so it survives a package
* boundary where two copies of this module can exist.
*/
export class GuestOrganizationUnresolvedError extends Error {
/** ADR-0112 wire code. */
readonly code = GUEST_ORGANIZATION_UNRESOLVED_CODE;
/** HTTP status a transport should answer — the spelling the HTTP doors read. */
readonly status = GUEST_ORGANIZATION_UNRESOLVED_STATUS;
/** The same number under ADR-0112 D5's spelling. */
readonly httpStatus = GUEST_ORGANIZATION_UNRESOLVED_STATUS;
/** Why no organization resolved — for the server's own log, never the wire. */
readonly reason: GuestOrganizationRefusalReason;

constructor(reason: GuestOrganizationRefusalReason) {
super(GUEST_ORGANIZATION_UNRESOLVED_MESSAGE);
this.name = 'GuestOrganizationUnresolvedError';
this.reason = reason;
}
}

/** True when `err` is ADR-0138 D4's located refusal. */
export function isGuestOrganizationUnresolvedError(err: unknown): err is GuestOrganizationUnresolvedError {
if (typeof err !== 'object' || err === null) return false;
const e = err as { code?: unknown; name?: unknown };
return e.code === GUEST_ORGANIZATION_UNRESOLVED_CODE && e.name === 'GuestOrganizationUnresolvedError';
}
25 changes: 25 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ export {
// security catalog — the `sys_metadata_activation` ledger, never a catalog
// row's `active` column — shared with the explainer and the security plugin.
readDisabledCatalogNames,
// [ADR-0138 D4] The guest entry's organization step, shared with the CRUD
// gate that raises D4's located refusal — one step, two askers.
resolveGuestOrganization,
resolveLocalizationContext,
type ResolvedAuthzContext,
type ResolveAuthzInput,
Expand All @@ -177,6 +180,28 @@ export {
type ResolveLocalizationInput,
} from './resolve-authz-context.js';

// [ADR-0131 D9, ADR-0138 D4] The ONE "which organization does a caller with no
// organization of its own act in?" decision. Two askers: the engine's
// system-context insert (`@objectstack/objectql`, which re-exports these same
// bindings) and the guest entry's organization step above.
export {
resolveSystemWriteOrganization,
type SystemWriteOrganizationDecision,
type SystemWriteRefusalReason,
} from './system-write-organization.js';

// [ADR-0138 D4] The located refusal an anonymous request meets where it needs
// an organization the deployment cannot resolve for it — a standard-catalog
// code (`NOT_IMPLEMENTED`), naming the missing site binding (D5).
export {
GuestOrganizationUnresolvedError,
isGuestOrganizationUnresolvedError,
GUEST_ORGANIZATION_UNRESOLVED_CODE,
GUEST_ORGANIZATION_UNRESOLVED_STATUS,
GUEST_ORGANIZATION_UNRESOLVED_MESSAGE,
type GuestOrganizationRefusalReason,
} from './guest-organization.js';

// [#18783] The EFFECTIVE object-permission map — ONE function behind both the
// `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions`
// (the map `current_user.can()` reads). The four folds moved here from
Expand Down
Loading
Loading