diff --git a/.changeset/23224-guest-grants-channel.md b/.changeset/23224-guest-grants-channel.md new file mode 100644 index 00000000000..60f8a6354e0 --- /dev/null +++ b/.changeset/23224-guest-grants-channel.md @@ -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. diff --git a/packages/core/src/security/guest-organization.ts b/packages/core/src/security/guest-organization.ts new file mode 100644 index 00000000000..6ba636f8d0d --- /dev/null +++ b/packages/core/src/security/guest-organization.ts @@ -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'; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 163db8d015d..f998710d887 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -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, @@ -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 diff --git a/packages/core/src/security/resolve-authz-context.guest-grants.test.ts b/packages/core/src/security/resolve-authz-context.guest-grants.test.ts new file mode 100644 index 00000000000..9cb4a0f69c0 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.guest-grants.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 E1 — the anonymous branch of `resolveAuthzContext` resolves the + * guest's grants: the sets bound to the `guest` anchor position (D3), in the + * organization the guest entry's organization step derives (D4). + * + * Each case pairs the reading with the control that tells it apart from the + * branch returning early, which is what the branch did before E1: a set bound + * to `guest` granted nothing (ADR-0090 D9, declared and not enforced). + * + * The engine double answers `find` from tables, counts every read it serves, + * and answers the registry's `getObject` for the objects it was built with — + * the two facts the organization step asks before it reads. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveAuthzContext, resolveGuestOrganization } from './resolve-authz-context.js'; +import { resolveSystemWriteOrganization } from './system-write-organization.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const GUEST_SET = 'public_catalog_reader'; +const ORG = 'org_only'; + +interface FakeOptions { + /** `sys_organization` rows; omitted = the object is not registered at all. */ + organizations?: Array<{ id: string }>; + /** The `guest` anchor's declared sets. */ + guestSets?: string[]; + /** Activation-ledger rows (`sys_metadata_activation`). */ + ledger?: Array>; + /** Extra tables. */ + tables?: Record; +} + +/** + * The fake engine's WHERE matcher: equality and `$in` per field. A combinator + * it does not implement is REFUSED, never read as a field name and answered + * silently wrong. + */ +function matchesWhere(row: any, where: any): boolean { + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) throw new Error('fakeEngine.find: unsupported logical operator ' + k); + const hit = v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(row[k]) : row[k] === v; + if (!hit) return false; + } + return true; +} + +function fakeEngine(opts: FakeOptions = {}) { + const reads: string[] = []; + const tables: Record = { + ...(opts.organizations ? { sys_organization: opts.organizations } : {}), + sys_metadata_activation: opts.ledger ?? [], + ...(opts.tables ?? {}), + }; + const registered = new Set(Object.keys(tables)); + const engine = { + registry: { getObject: (name: string) => (registered.has(name) ? { name } : undefined) }, + async find(object: string, q: any) { + reads.push(object); + const where = q?.where ?? {}; + const rows = (tables[object] ?? []).filter((r) => matchesWhere(r, where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }; + bindStaticSecurityCatalog(engine, { + positions: [{ name: 'guest', permissionSets: opts.guestSets ?? [] }, { name: 'everyone', permissionSets: ['member_default'] }], + permissions: [ + { name: GUEST_SET, tabPermissions: { storefront: 'visible' } }, + { name: 'second_reader' }, + { name: 'member_default' }, + { name: 'guest' }, + ], + }); + return { engine, reads }; +} + +const anonymous = (engine: unknown, extra: Record = {}) => + resolveAuthzContext({ ql: engine, headers: new Headers(), getSession: async () => undefined, ...extra }); + +describe('ADR-0138 D3 — a bound set reaches the guest entry, in the derived organization', () => { + it('carries the bound set and the one organization (D3 item 1, D4 item 1)', async () => { + const { engine } = fakeEngine({ organizations: [{ id: ORG }], guestSets: [GUEST_SET] }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx.permissions).toEqual([GUEST_SET]); + expect(ctx.tenantId).toBe(ORG); + expect(ctx.tabPermissions).toEqual({ storefront: 'visible' }); + expect(ctx.userId).toBeUndefined(); + // The control: the same engine, on a face that does not take the guest entry. + const failClosed = await anonymous(engine); + expect(failClosed.permissions).toEqual([]); + expect(failClosed.tenantId).toBeUndefined(); + }); + + it('nothing bound: no set, no organization, and no read issued (D3 item 2)', async () => { + const { engine, reads } = fakeEngine({ organizations: [{ id: ORG }], guestSets: [] }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx).toMatchObject({ positions: [], permissions: [], systemPermissions: [] }); + expect(ctx.tenantId).toBeUndefined(); + expect(reads).toEqual([]); + }); + + it('a set the catalog does not hold, and a switched-off set, confer nothing', async () => { + const { engine } = fakeEngine({ + organizations: [{ id: ORG }], + guestSets: [GUEST_SET, 'second_reader', 'no_such_set'], + ledger: [{ metadata_type: 'permission', name: 'second_reader', active: false }], + }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx.permissions).toEqual([GUEST_SET]); + }); + + it('a switched-off `guest` position grants nothing', async () => { + const { engine } = fakeEngine({ + organizations: [{ id: ORG }], + guestSets: [GUEST_SET], + ledger: [{ metadata_type: 'position', name: 'guest', active: false }], + }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx.permissions).toEqual([]); + }); + + it('the guest gains neither `everyone` nor any position, and no posture rung (D3 item 3)', async () => { + const { engine } = fakeEngine({ organizations: [{ id: ORG }], guestSets: [GUEST_SET] }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx.positions).toEqual([]); + expect(ctx.permissions).not.toContain('member_default'); + expect(ctx.posture).toBeUndefined(); + }); + +}); + +describe('ADR-0138 D4 — no derived organization, no set (D3 item 7)', () => { + it('several organizations: nothing granted, no organization chosen', async () => { + const { engine } = fakeEngine({ organizations: [{ id: 'org_a' }, { id: 'org_b' }], guestSets: [GUEST_SET] }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx.permissions).toEqual([]); + expect(ctx.tenantId).toBeUndefined(); + }); + + it('a walled posture: nothing granted, and no organization is read', async () => { + const { engine, reads } = fakeEngine({ organizations: [{ id: ORG }], guestSets: [GUEST_SET] }); + const ctx = await anonymous(engine, { guestEntry: true, tenancyPosture: 'isolated' }); + expect(ctx.permissions).toEqual([]); + expect(ctx.tenantId).toBeUndefined(); + expect(reads).not.toContain('sys_organization'); + }); + + it('a composition with no organization object: nothing granted, and the object is never read', async () => { + const { engine, reads } = fakeEngine({ guestSets: [GUEST_SET] }); + const ctx = await anonymous(engine, { guestEntry: true }); + expect(ctx.permissions).toEqual([]); + expect(reads).not.toContain('sys_organization'); + }); +}); + +describe('ADR-0138 D4 item 2 — the guest entry asks ADR-0131 D9\'s predicate, never a second spelling', () => { + const facts = [ + { name: 'one organization', organizations: [{ id: ORG }], posture: 'single' as const }, + { name: 'two organizations', organizations: [{ id: 'a' }, { id: 'b' }], posture: 'single' as const }, + { name: 'no organization', organizations: [], posture: 'single' as const }, + { name: 'no organization object', organizations: undefined, posture: 'single' as const }, + { name: 'a wall', organizations: [{ id: ORG }], posture: 'group' as const }, + ]; + for (const f of facts) { + it(`${f.name}: the same decision the predicate gives the same facts`, async () => { + const { engine } = fakeEngine({ organizations: f.organizations }); + const viaGuest = await resolveGuestOrganization(engine, f.posture); + const direct = await resolveSystemWriteOrganization({ + posture: f.posture, + probeOrganizations: async () => (f.organizations ?? []).map((o) => o.id), + organizationObjectRegistered: f.organizations !== undefined, + }); + expect(viaGuest).toEqual(direct); + }); + } + + it('no posture supplied reads as no wall', async () => { + const { engine } = fakeEngine({ organizations: [{ id: ORG }] }); + expect(await resolveGuestOrganization(engine, undefined)).toEqual({ kind: 'derived', organizationId: ORG }); + }); + + it('a failed organization read is loud, never zero organizations', async () => { + const engine = { + registry: { getObject: () => ({}) }, + async find() { throw new Error('connection reset'); }, + }; + await expect(resolveGuestOrganization(engine, 'single')).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + status: 503, + }); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 6fbbb9d4852..dd89345aa52 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -56,8 +56,10 @@ import { BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES, + GUEST_POSITION, ORGANIZATION_ADMIN_GRANTS, } from '@objectstack/spec'; +import { SystemObjectName } from '@objectstack/spec/system'; import type { AuthzPosture, TenancyPosture } from '@objectstack/spec/security'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { isValueDomainMember } from '@objectstack/spec/shared'; @@ -70,6 +72,7 @@ import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-m import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js'; import { derivePosture } from './posture-ladder.js'; import { securityCatalogReaderOf, type SecurityCatalogReader } from './security-catalog.js'; +import { resolveSystemWriteOrganization, type SystemWriteOrganizationDecision } from './system-write-organization.js'; import { METADATA_ACTIVATION_TABLE } from '../utils/metadata-activation-store.js'; /** The transport-agnostic authorization envelope produced from a request. */ @@ -140,6 +143,20 @@ export interface ResolveAuthzInput { * never made WORSE, only less strict. */ tenancyPosture?: TenancyPosture; + /** + * [ADR-0138 D3/D4] This face takes the EXPLICIT guest entry + * (`assembleExecutionContextOrGuest`): a request that resolves no principal + * is served as the guest, so the guest's grants are resolved for it — the + * sets bound to the `guest` anchor position, in the organization D4 derives + * ({@link resolveGuestAnchorGrants}). + * + * Only the face that serves the guest asks for them (D2's adoption rule: the + * guest-entry doors, classes 4 and 5). A face that answers an anonymous + * request 401 (the fail-closed default entry) omits it and pays no read for + * a guest it never serves — and no set bound to `guest` can reach a door that + * never adopted the guest entry. + */ + guestEntry?: boolean; } /** @@ -334,7 +351,9 @@ async function tryFind( /** * Resolve the authorization context for an inbound request. Anonymous requests - * yield `{ positions: [], permissions: [], ... }`. + * yield `{ positions: [], permissions: [], ... }` — and, on a face that takes + * the guest entry ({@link ResolveAuthzInput.guestEntry}), the guest's grants + * (ADR-0138 D3/D4, {@link resolveGuestAnchorGrants}). * * ⚠️ [commit 6a180e42d] This function used to document itself as "Always resolves — never * throws", and that total guarantee WAS the defect: the only way to always @@ -425,7 +444,25 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise> { + const named = new Map(); + if (!catalog) return named; + for (const positionName of positions) { + named.set(positionName, positionSetNamesOf((await catalog.resolve('position', positionName))?.definition)); + } + return named; +} + +type TabVisibility = 'visible' | 'hidden' | 'default_on' | 'default_off'; + +/** + * Fold held set bodies into a grant envelope: each set's name onto + * `permissions`, its system permissions onto `systemPermissions`, and its tab + * permissions merged by highest visibility — the derivation every held set + * takes, the user's (§6b) and the guest's ({@link resolveGuestAnchorGrants}) + * alike. Bodies arrive already filtered: a switched-off set is never handed in. + */ +function foldCatalogSetBodies( + target: { permissions: string[]; systemPermissions: string[]; tabPermissions?: Record }, + bodies: readonly CatalogSetBody[], +): void { + const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; + const mergedTabs: Record = {}; + for (const ps of bodies) { + if (ps.name && !target.permissions.includes(ps.name)) target.permissions.push(ps.name); + const sysPerms = ps.systemPermissions; + if (Array.isArray(sysPerms)) { + for (const p of sysPerms) { + if (typeof p === 'string' && !target.systemPermissions.includes(p)) target.systemPermissions.push(p); + } + } + const tabs = ps.tabPermissions; + if (tabs && typeof tabs === 'object' && !Array.isArray(tabs)) { + for (const [app, val] of Object.entries(tabs as Record)) { + if (typeof val !== 'string' || !(val in tabRank)) continue; + const cur = mergedTabs[app]; + if (!cur || tabRank[val] > tabRank[cur]) { + mergedTabs[app] = val as TabVisibility; + } + } + } + } + if (Object.keys(mergedTabs).length > 0) target.tabPermissions = mergedTabs; +} + +/** + * Whether the engine's registry holds `object`. A registry that cannot answer + * is not an answer — `true`, so the read is issued and decides (the + * activation-ledger guard's reading, {@link ledgerUnregistered}). + */ +function registryHoldsObject(ql: any, object: string): boolean { + const getObject = ql?.registry?.getObject; + if (typeof getObject !== 'function') return true; + try { + return !!getObject.call(ql.registry, object); + } catch { + return true; + } +} + +/** + * [ADR-0138 D4] The guest entry's organization step: the organization an + * anonymous request acts in, decided by `resolveSystemWriteOrganization` — + * ADR-0131 D9's predicate, the one a system-context write asks (D4 item 2, + * "never a second spelling of it"). Derived when the deployment holds exactly + * one organization under `single`; refused under a wall, at several + * organizations, and at none; and `no-organization-object` in a composition + * that registers no organization object at all. + * + * Server-side only: nothing the client sends is read (D4 item 1). + * + * Its facts, supplied here as the engine supplies its own: + * + * - the posture: the face's EFFECTIVE posture, the value the Layer 0 wall is + * handed. No posture is no wall (`single`), the reading + * {@link vetOrganizationClaim} gives an omitted posture. + * - the probe: at most two organization ids, read as the system — through + * {@link tryFind}, so an unprovisioned table is zero organizations and any + * other failed read is the loud `AuthzStoreUnavailableError`, never an + * invented count. + * - whether the organization object is registered, asked of the registry + * BEFORE the probe, as the engine's own probe asks it: an unregistered name + * is refused by the engine as "object not found", which is not a missing + * table, so it is never read. An engine with no `find` at all wires no + * data plane, so it registers none. + * + * Two askers, one step: the guest entry ({@link resolveGuestAnchorGrants}), + * and the CRUD gate that raises D4's located refusal for a guest that reaches + * a data operation with no organization (`@objectstack/plugin-security`, + * `GuestOrganizationUnresolvedError`). + */ +export async function resolveGuestOrganization( + ql: any, + tenancyPosture: TenancyPosture | undefined, +): Promise { + // No engine wired is no data plane at all — no organization can exist — and + // is not a failed read (`tryFind`'s own reading of it). + const organizationObjectRegistered = !!ql && typeof ql.find === 'function' + && registryHoldsObject(ql, SystemObjectName.ORGANIZATION); + return resolveSystemWriteOrganization({ + posture: tenancyPosture ?? 'single', + probeOrganizations: async () => { + if (!organizationObjectRegistered) return []; + const rows = await tryFind(ql, SystemObjectName.ORGANIZATION, {}, 2); + return rows + .map((row) => row?.id ?? row?._id) + .filter((id: unknown) => id !== undefined && id !== null && String(id) !== '') + .map((id: unknown) => String(id)); + }, + organizationObjectRegistered, + }); +} + +/** What the guest entry adds to the anonymous envelope ({@link resolveGuestAnchorGrants}). */ +interface GuestAnchorGrants { + /** The organization D4 derived — the guest's `tenantId`. */ + organizationId: string; + permissions: string[]; + systemPermissions: string[]; + tabPermissions?: Record; +} + +/** + * [ADR-0138 D3] The guest's grants: the permission sets bound to the `guest` + * anchor position, in the organization D4 derives — or `undefined` when the + * guest holds nothing. + * + * 1. **The binding is the position's own definition** (ADR-0131 D3/D4), read + * by {@link positionBoundSetNames}, the reader every position's bindings + * go through. No guest-specific storage, no second reader (D3 item 1). + * 2. **Nothing bound, nothing read.** The definition is read first, in + * process, only to learn whether anything is bound: with nothing bound the + * guest holds no set (D3 item 2, the ADR-0056 D2 deny baseline) and no row + * is read — the common anonymous request pays no query here. + * 3. **The organization comes first** (D3 item 7). No set is granted without + * D4's organization: a `refuse` decision, or a composition with no + * organization object, leaves the guest holding nothing and acting in no + * organization. The REFUSAL is not raised here — this envelope is + * assembled for every sessionless request the face serves, and D4 is asked + * only where the organization matters (D4 item 3) — but by the CRUD gate + * a guest data operation reaches. + * 4. **Deactivation is the activation ledger's** ({@link readDisabledCatalogNames}), + * as for every held position and set: a switched-off `guest` position + * grants nothing, and a switched-off set is dropped. + * 5. **The bodies fold as every held set's do** ({@link foldCatalogSetBodies}). + * A set bound to `guest` passed the strictest anchor tier where it was + * bound (D3 item 4); its names reach `permissions`, where the security + * plugin's set resolution treats them as any requested set. + * + * ⛔ Nothing else reaches the guest (D3 item 3). The `everyone` anchor and the + * platform-admin derivations live in {@link resolveUserAuthzGrants}, which a + * request with no user never reaches; the authenticated baseline is applied by + * the security plugin's set resolution to a caller with a user id only; and + * the position-name fold is closed for the guest there too + * (`SecurityPlugin.resolvePermissionSetsForContextUnmemoized`). + */ +async function resolveGuestAnchorGrants( + ql: any, + tenancyPosture: TenancyPosture | undefined, +): Promise { + if (!ql || typeof ql.find !== 'function') return undefined; + const catalog = securityCatalogReaderOf(ql); + const bound = (await positionBoundSetNames(catalog, [GUEST_POSITION])).get(GUEST_POSITION) ?? []; + if (bound.length === 0) return undefined; + + const organization = await resolveGuestOrganization(ql, tenancyPosture); + if (organization.kind !== 'derived') return undefined; + + const grants: GuestAnchorGrants = { + organizationId: organization.organizationId, + permissions: [], + systemPermissions: [], + }; + const disabled = await readDisabledCatalogNames(ql, [GUEST_POSITION], bound); + if (disabled.positions.has(GUEST_POSITION)) return grants; + const bodies: CatalogSetBody[] = []; + for (const name of bound) { + if (disabled.permissions.has(name)) continue; + const body = await catalogSetBody(catalog, name); + if (body && !bodies.some((b) => b.name === body.name)) bodies.push(body); + } + foldCatalogSetBodies(grants, bodies); + return grants; +} + /** * resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}. * @@ -1134,12 +1368,7 @@ export async function resolveUserAuthzGrants( if (grants.positions.length > 0) { // The definitions first (in-process), so the ONE ledger read can ask about // every position and every set this resolution may grant. - const namedByPosition = new Map(); - if (catalog) { - for (const positionName of grants.positions) { - namedByPosition.set(positionName, positionSetNamesOf((await catalog.resolve('position', positionName))?.definition)); - } - } + const namedByPosition = await positionBoundSetNames(catalog, grants.positions); disabled = await readDisabledCatalogNames( ql, grants.positions, @@ -1209,29 +1438,7 @@ export async function resolveUserAuthzGrants( // `deactivate_permission_set` dialog's promise ("existing assignments stay // in place but stop granting access"). Dropped BEFORE any derivation, the // same discipline the validity window gets at §6. - const psRows = psRowsAll.filter((r) => r.active); - const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; - const mergedTabs: Record = {}; - for (const ps of psRows) { - if (ps.name && !grants.permissions.includes(ps.name)) grants.permissions.push(ps.name); - const sysPerms = ps.systemPermissions; - if (Array.isArray(sysPerms)) { - for (const p of sysPerms) { - if (typeof p === 'string' && !grants.systemPermissions.includes(p)) grants.systemPermissions.push(p); - } - } - const tabs = ps.tabPermissions; - if (tabs && typeof tabs === 'object' && !Array.isArray(tabs)) { - for (const [app, val] of Object.entries(tabs as Record)) { - if (typeof val !== 'string' || !(val in tabRank)) continue; - const cur = mergedTabs[app]; - if (!cur || tabRank[val] > tabRank[cur]) { - mergedTabs[app] = val as 'visible' | 'hidden' | 'default_on' | 'default_off'; - } - } - } - } - if (Object.keys(mergedTabs).length > 0) grants.tabPermissions = mergedTabs; + foldCatalogSetBodies(grants, psRowsAll.filter((r) => r.active)); } // 6b-config. [#11663 L2] The DEPLOYMENT-CONFIG anchor for PLATFORM_ADMIN — diff --git a/packages/core/src/security/system-write-organization.ts b/packages/core/src/security/system-write-organization.ts new file mode 100644 index 00000000000..5b69cbaab74 --- /dev/null +++ b/packages/core/src/security/system-write-organization.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0131 D9 — the ONE "which organization does this resolve to?" predicate + * for a caller that carries no organization of its own: derived when exactly + * one organization exists under `single`, refused otherwise. + * + * ## Why it lives here, in `@objectstack/core` + * + * It has two askers, one layer apart: + * + * - the engine's system-context insert (`Engine.resolveSystemInsertOrganization`, + * `@objectstack/objectql`, #8844) — the write that carries no organization; + * - the guest entry's organization step ({@link resolveGuestOrganization}, + * ADR-0138 D4) — the anonymous request that carries no organization. + * + * ADR-0138 D4 item 2 binds the second to the first's predicate, "never a second + * spelling of it". `@objectstack/objectql` depends on this package, so the + * guest step cannot import the engine's copy; the predicate moved down to the + * layer both askers already depend on, and `@objectstack/objectql` re-exports + * these exact bindings unchanged (`tenancy/system-write-organization.ts`), so + * every existing import of it keeps resolving to this one function. The same + * relocation `isMissingTableError` made to `@objectstack/types` for the same + * reason: a second copy of a security-relevant classifier is the defect. + * + * The refusal each asker raises stays its own: the engine's + * `SystemWriteOrganizationRequiredError` speaks to an automation author about a + * write, the guest step's refusal speaks to an anonymous caller about a door. + * Only the DECISION is shared. + */ + +import type { TenancyPosture } from '@objectstack/spec/security'; + +/** + * Why no organization could be derived. Each is a refusal: nothing defaults + * to any organization but the derivable one (ADR-0131 D9). + */ +export type SystemWriteRefusalReason = + /** + * A walled posture (`group` / `isolated`). Organizations are a real boundary + * here, so "the install's organization" is not a thing that exists. + */ + | 'walled-posture' + /** + * The posture says `single` but the data holds several organizations. The + * posture is what the deployment ASKED for; the count is what the data + * actually is, and where they disagree there is no unambiguous answer — the + * topology is refused rather than given a guessed default. + * #8686's backfill draws the same line (`skipped-ambiguous-organization`). + */ + | 'ambiguous-organization' + /** + * The posture says `single`, the composition registers the organization + * object, and it holds NO organization. Under ADR-0131 D3 the Default + * Organization exists before anything writes (plugin-auth creates it at + * boot, and a failure to is a boot error), so this is the boot invariant + * broken — and there is no owner to derive, which D9 refuses rather than + * guessing. + */ + | 'no-organization'; + +/** + * What a caller with no organization of its own resolves to. + * + * `refuse` is a decision, not a throw: each asker turns it into its own + * refusal, so the decision stays a pure, directly-testable function of the + * facts. + */ +export type SystemWriteOrganizationDecision = + /** + * Nothing to resolve — this COMPOSITION registers no organization object at + * all (a lean embedding, a bare-kernel test), so no organization can exist + * and there is nothing to stamp or to refuse in the name of. + * + * ⚠️ Deliberately NOT "the install has no organization yet". That reading + * used to share this kind, and it was the normal state of a fresh `single` + * boot while the Default Organization arrived only with the first sign-up. + * ADR-0131 D3 made that organization load-bearing — it exists before the + * application seeds and before the listener — so a `single` install that + * registers `sys_organization` and holds none of it is the boot invariant + * broken, and is refused (`no-organization`) like every other topology with + * no derivable owner (D9). Only the composition with no organization object + * keeps this branch; whether D9's refusal also reaches it is ADR-0131 C8's + * question, not this one's. + */ + | { kind: 'no-organization-object' } + /** Exactly one organization under `single`, so the answer is derivable. */ + | { kind: 'derived'; organizationId: string } + /** No derivable answer: refuse loudly rather than default to any organization. */ + | { kind: 'refuse'; reason: SystemWriteRefusalReason; organizationCount?: number }; + +/** + * Resolve the organization a caller with no organization of its own acts in + * (ADR-0131 D9): derived at exactly one organization under `single`, refused + * at zero, at several, and under a wall. + * + * `probeOrganizations` is called ONLY on the single-tenant branch — a walled + * posture is refused without asking the database anything, because the answer + * could not change the verdict. + * + * `organizationObjectRegistered` separates the two facts an empty probe can + * mean: an install with the organization object and none of its rows (refused, + * `no-organization`) and a composition with no organization object at all + * (`no-organization-object`, unchanged). It is a required input, not a + * defaulted one: a caller that does not know the answer must not be handed the + * permissive reading by omission. + */ +export async function resolveSystemWriteOrganization(args: { + posture: TenancyPosture; + /** Organization ids, capped at 2 by the caller — only "0 / 1 / several" matters. */ + probeOrganizations: () => Promise; + /** Does this composition register the organization object (`sys_organization`) at all? */ + organizationObjectRegistered: boolean; +}): Promise { + if (args.posture !== 'single') { + return { kind: 'refuse', reason: 'walled-posture' }; + } + const ids = await args.probeOrganizations(); + if (ids.length === 0) { + return args.organizationObjectRegistered + ? { kind: 'refuse', reason: 'no-organization', organizationCount: 0 } + : { kind: 'no-organization-object' }; + } + if (ids.length === 1) return { kind: 'derived', organizationId: ids[0] }; + return { kind: 'refuse', reason: 'ambiguous-organization', organizationCount: ids.length }; +} diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts index e5ccae6c34c..5faf70bad3f 100644 --- a/packages/objectql/src/system-write-organization.test.ts +++ b/packages/objectql/src/system-write-organization.test.ts @@ -55,6 +55,7 @@ import { // re-exports. Same reasoning, and the same shape, as the core precedent // `packages/core/src/service-resolution-discriminator.contract.test.ts`. import * as barrel from './index.js'; +import { resolveSystemWriteOrganization as coreResolveSystemWriteOrganization } from '@objectstack/core'; const ORG_ID = 'org_msokm9oaz0cal87q'; const SECOND_ORG_ID = 'org_second'; @@ -555,4 +556,15 @@ describe('#14936 the published recognizer for the org-less system-write refusal' expect(barrel.SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE).toBe(SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE); expect(barrel.isSystemWriteOrganizationRequiredError).toBe(isSystemWriteOrganizationRequiredError); }); + + it('[ADR-0138 D4] re-exports the ONE predicate @objectstack/core holds, never a copy', () => { + // The guest entry's organization step asks the same decision a + // system-context write asks (D4 item 2, "never a second spelling of it"), + // so the predicate lives one layer down and this package re-exports the + // binding itself. A copy here would compile, pass every decision case above, + // and drift from the guest's reading silently - identity is the only pin + // that cannot. + expect(resolveSystemWriteOrganization).toBe(coreResolveSystemWriteOrganization); + expect(barrel.resolveSystemWriteOrganization).toBe(coreResolveSystemWriteOrganization); + }); }); diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index a06a45d1f42..8a1c6000ac0 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -105,6 +105,11 @@ import type { TenancyPosture } from '@objectstack/spec/security'; import { SystemObjectName } from '@objectstack/spec/system'; +import { + resolveSystemWriteOrganization, + type SystemWriteOrganizationDecision, + type SystemWriteRefusalReason, +} from '@objectstack/core'; /** * The table the "how many organizations does this install have?" probe counts — @@ -221,95 +226,24 @@ export function carriesOrganization(value: unknown): boolean { } /** - * What a system-context write's organization resolves to. - * - * `refuse` is a decision, not a throw: the caller turns it into - * {@link SystemWriteOrganizationRequiredError} so the decision stays a pure, + * [ADR-0138 D4] The decision — what a system-context write's organization + * resolves to — is `@objectstack/core`'s `resolveSystemWriteOrganization`, + * re-exported here as the SAME binding, never a wrapper or a copy. + * + * It moved down one layer because it has a second asker: the guest entry's + * organization step (`resolveGuestOrganization`, `@objectstack/core`), which + * ADR-0138 D4 item 2 binds to this very predicate, "never a second spelling of + * it" — and `@objectstack/core` cannot import this package (the dependency runs + * the other way). Every existing import of the predicate and its two types from + * this module or from the package barrel keeps resolving to the one function; + * the identity is pinned in `system-write-organization.test.ts`. + * + * `refuse` is a decision, not a throw: the engine turns it into + * {@link SystemWriteOrganizationRequiredError}, so the decision stays a pure, * directly-testable function of the facts. */ -export type SystemWriteOrganizationDecision = - /** - * Nothing to resolve — this COMPOSITION registers no organization object at - * all (a lean embedding, a bare-kernel test), so no organization can exist - * and there is nothing to stamp or to refuse in the name of. The write lands - * as it always has. - * - * ⚠️ Deliberately NOT "the install has no organization yet". That reading - * used to share this kind, and it was the normal state of a fresh `single` - * boot while the Default Organization arrived only with the first sign-up. - * ADR-0131 D3 made that organization load-bearing — it exists before the - * application seeds and before the listener — so a `single` install that - * registers `sys_organization` and holds none of it is the boot invariant - * broken, and is refused (`no-organization`) like every other topology with - * no derivable owner (D9). Only the composition with no organization object - * keeps this branch; whether D9's refusal also reaches it is ADR-0131 C8's - * question, not this one's. - */ - | { kind: 'no-organization-object' } - /** Binding point 1: exactly one organization, so the answer is derivable. */ - | { kind: 'derived'; organizationId: string } - /** Binding point 2: refuse loudly rather than default to `__global__`. */ - | { kind: 'refuse'; reason: SystemWriteRefusalReason; organizationCount?: number }; - -export type SystemWriteRefusalReason = - /** - * A walled posture (`group` / `isolated`). Organizations are a real boundary - * here, so "the install's organization" is not a thing that exists. - */ - | 'walled-posture' - /** - * The posture says `single` but the data holds several organizations. The - * posture is what the deployment ASKED for; the count is what the data - * actually is, and where they disagree there is no unambiguous answer — the - * topology falls under binding point 2 rather than getting a guessed default. - * #8686's backfill draws the same line (`skipped-ambiguous-organization`). - */ - | 'ambiguous-organization' - /** - * The posture says `single`, the composition registers the organization - * object, and it holds NO organization. Under ADR-0131 D3 the Default - * Organization exists before anything writes (plugin-auth creates it at - * boot, and a failure to is a boot error), so this is the boot invariant - * broken — and there is no owner to derive, which D9 refuses rather than - * writing a row nobody owns. - */ - | 'no-organization'; - -/** - * Resolve the organization a system-context write should carry (ADR-0131 D9): - * derived at exactly one organization under `single`, refused at zero, at - * several, and under a wall. - * - * `probeOrganizations` is called ONLY on the single-tenant branch — a walled - * posture is refused without asking the database anything, because the answer - * could not change the verdict. - * - * `organizationObjectRegistered` separates the two facts an empty probe can - * mean: an install with the organization object and none of its rows (refused, - * `no-organization`) and a composition with no organization object at all - * (`no-organization-object`, unchanged). It is a required input, not a - * defaulted one: a caller that does not know the answer must not be handed the - * permissive reading by omission. - */ -export async function resolveSystemWriteOrganization(args: { - posture: TenancyPosture; - /** Organization ids, capped at 2 by the caller — only "0 / 1 / several" matters. */ - probeOrganizations: () => Promise; - /** Does this composition register the organization object ({@link ORGANIZATION_OBJECT}) at all? */ - organizationObjectRegistered: boolean; -}): Promise { - if (args.posture !== 'single') { - return { kind: 'refuse', reason: 'walled-posture' }; - } - const ids = await args.probeOrganizations(); - if (ids.length === 0) { - return args.organizationObjectRegistered - ? { kind: 'refuse', reason: 'no-organization', organizationCount: 0 } - : { kind: 'no-organization-object' }; - } - if (ids.length === 1) return { kind: 'derived', organizationId: ids[0] }; - return { kind: 'refuse', reason: 'ambiguous-organization', organizationCount: ids.length }; -} +export { resolveSystemWriteOrganization }; +export type { SystemWriteOrganizationDecision, SystemWriteRefusalReason }; /** * Compose the refusal message. diff --git a/packages/plugins/plugin-security/src/guest-grants-channel.test.ts b/packages/plugins/plugin-security/src/guest-grants-channel.test.ts new file mode 100644 index 00000000000..7df5aeacd8d --- /dev/null +++ b/packages/plugins/plugin-security/src/guest-grants-channel.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 E1 — the security plugin's half of the guest's grants channel. + * + * - D3 (Enforced by): the set resolution treats the sets the guest envelope + * carries — the `guest` anchor's bindings `resolveAuthzContext` resolved — + * as any requested set; and the position-name fold, the authenticated + * baseline and `everyone` never reach the guest (D3 item 3). + * - D4: a guest that reaches a data operation acting in no organization, on + * a deployment where the guest entry's organization step refuses, meets the + * located refusal at the CRUD gate — `NOT_IMPLEMENTED` (501), before the + * operation runs and before any set resolves. + * + * Every pin carries its control: the same resolution for a signed-in human + * (who still folds and still takes the baseline), and the same gate on a + * single-organization deployment (an ordinary 403 for a guest holding nothing). + * + * Harness mirrors `zero-set-deny-baseline.test.ts`; the engine double adds the + * organization rows and the registry the organization step reads. Synthetic. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; + +const SCHEMAS: Record = { + ledger: { name: 'ledger', fields: { title: { type: 'text', label: 'Title' } } }, + catalog: { name: 'catalog', fields: { title: { type: 'text', label: 'Title' } } }, +}; + +const READ = { allowRead: true }; +const MEMBER_SET = { name: 'member_default', label: 'Synthetic member', objects: { ledger: READ } } as unknown as PermissionSet; +/** The set an administrator bound to the `guest` anchor. */ +const BOUND_SET = { name: 'synth_public_catalog', label: 'Synthetic catalog', objects: { catalog: READ } } as unknown as PermissionSet; +/** A set that merely shares the anchor's NAME. */ +const NAMED_LIKE_ANCHOR = { name: 'guest', label: 'Named like the anchor', objects: { ledger: READ } } as unknown as PermissionSet; + +type Deployment = { organizations: string[] | undefined }; + +async function boot(deployment: Deployment) { + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const registered: Record = {}; + const organizationRows = (deployment.organizations ?? []).map((id) => ({ id })); + const organizationReads = vi.fn(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => SCHEMAS[name], + registry: { getObject: (name: string) => (name === 'sys_organization' && deployment.organizations ? { name } : undefined) }, + find: vi.fn(async (object: string, query: any) => { + if (object === 'sys_organization') { + organizationReads(); + return organizationRows.slice(0, query?.limit ?? organizationRows.length); + } + return []; + }), + findOne: vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query); + return null; + }), + }, + metadata: { + get: async (_type: string, name: string) => SCHEMAS[name], + list: async () => [MEMBER_SET, BOUND_SET, NAMED_LIKE_ANCHOR], + }, + }; + const ctx: Record = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); + const security = registered['security'] as { resolvePermissionSetsForContext(context?: unknown): Promise }; + return { middleware: middlewares[0], security, organizationReads, logger: ctx.logger as { warn: ReturnType } }; +} + +type Verdict = { admitted: true } | { admitted: false; ran: boolean; code?: unknown; status?: unknown; message?: string }; + +async function run( + middleware: (opCtx: any, next: () => Promise) => Promise, + object: string, + operation: string, + context: Record, +): Promise { + let ran = false; + const opCtx: Record = { object, operation, context: { ...context }, options: {}, ast: { where: {} } }; + if (operation === 'insert') opCtx.data = { title: 'SYNTH' }; + try { + await middleware(opCtx, async () => { ran = true; }); + return { admitted: true }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown; message?: string }; + return { admitted: false, ran, code: err.code, status: err.status ?? err.statusCode, message: err.message }; + } +} + +const names = (sets: PermissionSet[]) => sets.map((s) => s.name).sort(); + +const GUEST = { principalKind: 'guest', positions: ['guest'], isSystem: false } as const; + +describe('ADR-0138 D3 — the set resolution treats the guest\'s bound sets as any requested set', () => { + it('a guest carrying the bound set resolves exactly that set', async () => { + const { security } = await boot({ organizations: ['org_one'] }); + const sets = await security.resolvePermissionSetsForContext({ ...GUEST, permissions: [BOUND_SET.name], tenantId: 'org_one' }); + expect(names(sets)).toEqual([BOUND_SET.name]); + }); + + it('the position-name fold never reaches the guest (D3 item 3)', async () => { + const { security } = await boot({ organizations: ['org_one'] }); + const guest = await security.resolvePermissionSetsForContext({ ...GUEST, permissions: [], tenantId: 'org_one' }); + expect(names(guest)).toEqual([]); + // Control: the fold is real — a signed-in human holding a position of that + // name still resolves the same-named set through it, beside the baseline. + const human = await security.resolvePermissionSetsForContext({ + principalKind: 'human', userId: 'u_synth', positions: ['guest'], permissions: [], tenantId: 'org_one', + }); + expect(names(human)).toEqual(['guest', 'member_default']); + }); + + it('the authenticated baseline never reaches the guest (D3 item 3)', async () => { + const { security } = await boot({ organizations: ['org_one'] }); + const guest = await security.resolvePermissionSetsForContext({ ...GUEST, permissions: [BOUND_SET.name], tenantId: 'org_one' }); + expect(names(guest)).not.toContain('member_default'); + // Control: the same request with a user id takes the baseline additively. + const human = await security.resolvePermissionSetsForContext({ + principalKind: 'human', userId: 'u_synth', positions: [], permissions: [BOUND_SET.name], tenantId: 'org_one', + }); + expect(names(human)).toEqual(['member_default', BOUND_SET.name]); + }); +}); + +describe('ADR-0138 D4 — the located refusal at the CRUD gate', () => { + it('several organizations: a guest with no organization is refused NOT_IMPLEMENTED (501), before the operation runs', async () => { + const { middleware, logger } = await boot({ organizations: ['org_a', 'org_b'] }); + for (const operation of ['find', 'insert']) { + const verdict = await run(middleware, 'catalog', operation, { ...GUEST, permissions: [] }); + expect(verdict, operation).toMatchObject({ admitted: false, ran: false, code: 'NOT_IMPLEMENTED', status: 501 }); + expect((verdict as { message?: string }).message).toMatch(/site binding/); + // The caller learns nothing of the deployment. + expect((verdict as { message?: string }).message).not.toMatch(/org_a|org_b|ambiguous|\b2\b/); + } + // The operator is told why — once. + const lines = logger.warn.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('resolves no organization for the guest')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('ambiguous-organization'); + }); + + it('control — one organization: the same guest is denied 403 for holding nothing, never refused 501', async () => { + const { middleware } = await boot({ organizations: ['org_one'] }); + expect(await run(middleware, 'catalog', 'find', { ...GUEST, permissions: [] })) + .toMatchObject({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 }); + }); + + it('control — a composition with no organization object: denied 403, and no organization is read', async () => { + const { middleware, organizationReads } = await boot({ organizations: undefined }); + expect(await run(middleware, 'catalog', 'find', { ...GUEST, permissions: [] })) + .toMatchObject({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 }); + expect(organizationReads).not.toHaveBeenCalled(); + }); + + it('a guest that carries its organization is not re-asked, and its bound set admits it', async () => { + const { middleware, organizationReads } = await boot({ organizations: ['org_one'] }); + expect(await run(middleware, 'catalog', 'find', { ...GUEST, permissions: [BOUND_SET.name], tenantId: 'org_one' })) + .toEqual({ admitted: true }); + expect(organizationReads).not.toHaveBeenCalled(); + // …and only the object it names. + expect(await run(middleware, 'ledger', 'find', { ...GUEST, permissions: [BOUND_SET.name], tenantId: 'org_one' })) + .toMatchObject({ admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 }); + }); + + it('a signed-in caller with no active organization is never refused by the guest rule', async () => { + const { middleware } = await boot({ organizations: ['org_a', 'org_b'] }); + const verdict = await run(middleware, 'ledger', 'find', { principalKind: 'human', userId: 'u_synth', positions: [], permissions: [] }); + expect(verdict).not.toMatchObject({ code: 'NOT_IMPLEMENTED' }); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 691eeef3992..b9d118d006f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -138,6 +138,9 @@ import { MasterReferenceMissingError, MaskedValueWriteError, } from './errors.js'; +// [ADR-0138 D4] The guest entry's organization step, and the located refusal +// the CRUD gate raises where it refuses. +import { GuestOrganizationUnresolvedError, resolveGuestOrganization } from '@objectstack/core'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; @@ -1454,6 +1457,14 @@ export class SecurityPlugin implements Plugin { */ private readonly nameFoldWarned = new Set(); + /** + * [ADR-0138 D4] The refusal reasons {@link refuseGuestWithoutOrganization} + * has already reported to the operator — once per reason per instance, for + * the reason {@link nameFoldWarned} gives: the condition is a deployment + * fact, and a per-request line is a filtered one. + */ + private readonly guestOrganizationRefusalWarned = new Set(); + /** * [#21729] Alternate matches other plugins contributed to the platform's * ownership floor, keyed by contributing plugin — read on every @@ -6466,7 +6477,17 @@ export class SecurityPlugin implements Plugin { private async resolvePermissionSetsForContextUnmemoized( context: any, ): Promise { - const positions = context?.positions ?? []; + // [ADR-0138 D3] The GUEST requests exactly the sets its envelope carries — + // the `guest` anchor's bindings, which `resolveAuthzContext` resolved in + // the organization D4 derived — and they are treated as any requested set + // below. Its position is NOT folded into the request: the position-name + // fold (#13419's ruling retires it) would hand an anonymous caller any set + // that merely shares the anchor's name, the one channel D3 item 3 says + // never reaches the guest. The baseline does not reach it either (applied + // only to a caller with a user id, below), and `everyone` is never among + // its positions. + const isGuest = context?.principalKind === 'guest' && !context?.userId; + const positions = isGuest ? [] : (context?.positions ?? []); const explicitPermissionSets = context?.permissions ?? []; const requested = [...positions, ...explicitPermissionSets]; // [ADR-0090 D10] An AGENT principal's grants are EXACTLY its scope-derived @@ -8682,6 +8703,7 @@ export class SecurityPlugin implements Plugin { if (isPrincipalLessContext(context)) { throw principalLessDenial(operation, object); } + await this.refuseGuestWithoutOrganization(context, operation, object); let permissionSets: PermissionSet[] = []; try { @@ -8718,6 +8740,56 @@ export class SecurityPlugin implements Plugin { return { permissionSets, delegatorSets, delegatorContext }; } + /** + * [ADR-0138 D4] The located refusal: a GUEST that reaches a data operation + * acting in no organization, on a deployment where the guest entry's + * organization step refuses — a wall, several organizations, or none. + * + * Raised HERE, at the CRUD gate, because this is where the guest's + * organization matters (D4 item 3: to resolve its grants and to scope its + * data operation). The guest entry cannot raise it: the runtime face + * assembles the guest envelope for every sessionless request before any + * route is matched, so a refusal there would replace every anonymous 401 and + * reach the flow door D4 leaves alone. The entry instead records nothing it + * cannot use — no organization and no set (`resolveAuthzContext`) — and this + * gate asks the SAME step (`resolveGuestOrganization`, one step, two askers) + * for a guest that carries no organization. Before any set is resolved: where + * D4 refuses, D3 is never reached (D3 item 7). And ahead of the prologue's + * fail-closed `catch` on purpose: that net turns every throw of the set + * resolution into a generic `PERMISSION_DENIED`, which is the disguise the + * located refusal removes. + * + * A guest that carries an organization was derived one by the entry, and a + * decision other than `refuse` (`derived`, or a composition with no + * organization object) leaves the guest to the gate as it is — denied while + * it holds no set (D3 item 2). + */ + private async refuseGuestWithoutOrganization(context: any, operation: string, object: string): Promise { + // A system context never reaches here: both callers of + // {@link resolveOperationPrincipals} return before it for one (the + // middleware's system short-circuit, and `checkControlledByParentWrite`'s + // first exit), so this rule reads no elevation of its own. + if (context?.principalKind !== 'guest' || context?.userId) return; + const tenantId = context?.tenantId; + if (typeof tenantId === 'string' && tenantId !== '') return; + const decision = await resolveGuestOrganization(this.ql, this.tenancyPosture); + if (decision.kind !== 'refuse') return; + if (!this.guestOrganizationRefusalWarned.has(decision.reason)) { + this.guestOrganizationRefusalWarned.add(decision.reason); + this.logger.warn?.( + `[security] an anonymous request reached '${operation}' on '${object}' and was refused: this deployment ` + + `resolves no organization for the guest (${decision.reason}, tenancy posture '${this.tenancyPosture}'` + + `${decision.organizationCount !== undefined ? `, ${decision.organizationCount} organization(s)` : ''}). ` + + `ADR-0138 D4 derives the guest's organization only on a single-organization deployment, and no site ` + + `binding (D5) exists yet to name one per request, so every anonymous data operation here answers ` + + `501 NOT_IMPLEMENTED. Serve the endpoint to signed-in callers (authRequired: true), or run it on a ` + + `single-organization deployment. Reported once per reason.`, + { operation, object, reason: decision.reason }, + ); + } + throw new GuestOrganizationUnresolvedError(decision.reason); + } + /** * [ADR-0055] `ISecurityService.checkControlledByParentWrite` — what the * master-detail write check answers for a by-id UPDATE of `recordId` on diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index f38e449cef0..bba66d16122 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -227,6 +227,55 @@ describe('SharingService.buildReadFilter', () => { }); }); +// [ADR-0138 D3 item 6, as the maintainer's ruling C amends it — comment +// 6148183570 on #23266] The guest reaches rows through share links only, never +// through the object's sharing model: its read filter is deny-all on every +// object record sharing scopes. Share links are redeemed at their own door and +// never pass through this filter (the door-level control is in +// `packages/qa/dogfood/test/guest-grants-channel.dogfood.test.ts`). +describe('SharingService.buildReadFilter — the guest never takes the OWD branch', () => { + const GUEST = { principalKind: 'guest', positions: ['guest'], permissions: ['bound_reader'], isSystem: false } as const; + const DENY_ALL = { id: '__deny_all__' }; + let svc: SharingService; + beforeEach(() => { + svc = new SharingService({ + engine: makeFakeEngine({ + account: ACCOUNT_SCHEMA, + lead: LEAD_SCHEMA, + whiteboard: CANON_PUBLIC_RW_SCHEMA, + line_item: { + name: 'line_item', + sharingModel: 'controlled_by_parent', + fields: { id: {}, name: {}, owner_id: {}, account: {} }, + }, + note: ORPHAN_SCHEMA, + sys_record_share: { name: 'sys_record_share' }, + }), + }); + }); + + for (const [object, model] of [ + ['account', 'private'], + ['lead', 'public_read'], + ['whiteboard', 'public_read_write'], + ['line_item', 'controlled_by_parent'], + ['note', 'private, with no owner column (the OWD-public fall-through)'], + ] as const) { + it(`${model}: the guest's read filter is deny-all`, async () => { + expect(await svc.buildReadFilter(object, { ...GUEST } as any)).toEqual(DENY_ALL); + }); + } + + it('control: a signed-in member still takes the OWD branch — no filter on public_read and public_read_write', async () => { + expect(await svc.buildReadFilter('lead', { principalKind: 'human', userId: 'u1' } as any)).toBeNull(); + expect(await svc.buildReadFilter('whiteboard', { principalKind: 'human', userId: 'u1' } as any)).toBeNull(); + }); + + it('control: the system context is never the guest — no filter', async () => { + expect(await svc.buildReadFilter('lead', { principalKind: 'guest', isSystem: true } as any)).toBeNull(); + }); +}); + describe('SharingService.canEdit', () => { let engine: ReturnType; let svc: SharingService; diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index c8ca7f2e048..932cef87fd6 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -121,6 +121,15 @@ export function effectiveSharingModel(schema: any): 'private' | 'read' | 'public return 'private'; } +/** + * [ADR-0138 D1/D3] The anonymous principal the guest entry assembles — + * `principalKind: 'guest'` with no user id. A system context is never one: the + * bypass is asked first wherever this is. + */ +function isGuestPrincipal(context: ExecutionContext): boolean { + return context?.principalKind === 'guest' && !context?.userId; +} + /** * [#5859 / #5852] The caller's ACTIVE ORGANIZATION as carried by an execution * context — the value `HierarchyScopeContext.organizationId` (the authoritative @@ -456,6 +465,19 @@ export class SharingService implements ISharingService { const schema = this.engine.getSchema?.(object); if (!schema) return null; + // [ADR-0138 D3 item 6, as the maintainer's ruling C amends it — comment + // 6148183570 on #23266] The GUEST never takes the OWD branch below: not the + // object's `sharingModel` (a `public_read` or `public_read_write` object, a + // `controlled_by_parent` detail), and not its owner-less or phantom-anchor + // fall-throughs, which read as OWD-public. `externalSharingModel` is not + // read here for anyone (#23268). The guest owns nothing (ADR-0138 D1), so + // it reaches rows only through share links — and a link is redeemed at its + // own door, which checks the token and `publicSharing.eligibility` and + // reads the one linked record itself (`share-link-routes.ts`), never + // through this filter. So for the guest, on every object record sharing + // scopes, the answer is deny-all, whatever its bound sets admit at the + // object level. + if (isGuestPrincipal(context)) return { id: '__deny_all__' }; if (effectiveSharingModel(schema) !== 'private') return null; if (!hasOwnerField(schema)) return null; // [#7858] …and an `owner_id` the REGISTRY injected into a FEDERATED object diff --git a/packages/qa/dogfood/test/guest-grants-channel.dogfood.test.ts b/packages/qa/dogfood/test/guest-grants-channel.dogfood.test.ts new file mode 100644 index 00000000000..1ca353ce025 --- /dev/null +++ b/packages/qa/dogfood/test/guest-grants-channel.dogfood.test.ts @@ -0,0 +1,529 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0138 E1 — the guest's grants channel (D3) and the organization it acts in +// (D4), at the real door: an `authRequired: false` endpoint of +// `type: 'object_operation'` (D2 class 4), on a booted stack. +// +// What the record decides, and what this file holds at the door: +// +// - D3 item 1: the guest envelope carries the sets bound to the `guest` anchor +// position, read by the one binding reader every position uses — the +// position's own declared sets (ADR-0131 D3/D4) — in the organization D4 +// resolves. A bound read set grants that object, and only that object. +// - D3 item 2: nothing bound, nothing granted (the denial is the empty state). +// - D3 item 3: one channel — the authenticated baseline, the `everyone` anchor +// and the position-name fold never reach the guest. +// - D4: the organization is derived when it is unique; a deployment that holds +// several answers the located refusal at the door that needs one. +// - D4 item 4: the public form doors keep their own posture, unchanged. +// - D3 item 6, as the maintainer's ruling C amends it (comment 6148183570 on +// #23266): the guest's row scope is share links only — it never takes the +// OWD branch, so a bound read set reads 0 rows under every sharing model, +// while a share link still serves its one record. +// +// THE BINDING IS INSTALLED THROUGH THE REGISTRY'S BARE SLOT, by the test. The +// `guest` anchor is a built-in position `@objectstack/plugin-security` declares +// as packaged metadata, and this deployment offers no door that writes a set +// into its definition: the metadata door refuses an in-place write on a +// packaged position, and the audience-binding acceptance takes `everyone` +// suggestions only. The bare slot is where an environment definition of the +// anchor answers first (ADR-0005), which is the state D3 reads; installing it +// directly is the only way a boot can reach that state today. The case at the +// foot of this file records the refusal at the metadata door. +// +// The fixture is inline and throwaway, like the policy and guest fixtures beside +// it. Every anonymous endpoint carries the armed rate limit ADR-0121 D6 makes its +// publish obligation. + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { defineStack, defineView } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { definePermissionSet } from '@objectstack/spec/security'; +import type { ApiEndpoint } from '@objectstack/spec/api'; +import type { IObjectQLEngine, ISecurityService, SecurityContext } from '@objectstack/spec/contracts'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import showcaseStack from '@objectstack/example-showcase'; +import crmStack from '@objectstack/example-crm'; +import multiPackageStack from '@objectstack/example-multi-package'; + +const SYS = { context: { isSystem: true } } as const; +const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 1_000 }; +const BASE = '/api/v1/apps/e1guest'; +const FORM_SLUG = 'e1guest-intake'; + +/** One object per sharing model, plus the controls. */ +const OBJ = { + private: 'e1guest_private', + publicRead: 'e1guest_public_read', + publicReadExternal: 'e1guest_public_read_ext', + publicReadWrite: 'e1guest_public_rw', + parent: 'e1guest_parent', + child: 'e1guest_child', + unbound: 'e1guest_unbound', + fold: 'e1guest_fold', + intake: 'e1guest_intake', +} as const; + +const text = () => ({ name: Field.text({ label: 'Name', required: true }) }); +const object = (name: string, sharingModel: string, extra: Record = {}) => + ObjectSchema.create({ name, label: name, pluralLabel: name, sharingModel: sharingModel as any, fields: text(), ...extra }); + +const objects = [ + object(OBJ.private, 'private'), + // The share-link control's object: links on, and an eligibility predicate + // only the first seed row meets. + object(OBJ.publicRead, 'public_read', { + publicSharing: { enabled: true, eligibility: `record.name == '${OBJ.publicRead}-seed-1'` }, + }), + object(OBJ.publicReadExternal, 'public_read', { externalSharingModel: 'public_read' }), + object(OBJ.publicReadWrite, 'public_read_write'), + object(OBJ.parent, 'public_read'), + ObjectSchema.create({ + name: OBJ.child, + label: OBJ.child, + pluralLabel: OBJ.child, + sharingModel: 'controlled_by_parent', + fields: { ...text(), parent: Field.masterDetail(OBJ.parent, { label: 'Parent', required: true }) }, + }), + object(OBJ.unbound, 'public_read'), + object(OBJ.fold, 'public_read'), + object(OBJ.intake, 'private'), +]; + +/** The set the test binds to the `guest` anchor: read on the sharing-model objects, create on one. */ +const GUEST_SET = 'e1guest_reader'; +const GuestReader = definePermissionSet({ + name: GUEST_SET, + label: 'E1 guest reader', + objects: { + [OBJ.private]: { allowRead: true }, + [OBJ.publicRead]: { allowRead: true }, + [OBJ.publicReadExternal]: { allowRead: true }, + [OBJ.publicReadWrite]: { allowRead: true, allowCreate: true }, + [OBJ.parent]: { allowRead: true }, + [OBJ.child]: { allowRead: true }, + }, +}); + +/** A set that shares the anchor's NAME — the position-name fold's only route to the guest. */ +const FoldSet = definePermissionSet({ + name: 'guest', + label: 'Named like the guest anchor', + objects: { [OBJ.fold]: { allowRead: true } }, +}); + +/** The set the test binds to the `everyone` anchor — authenticated members only. */ +const EVERYONE_SET = 'e1guest_everyone_reader'; +const EveryoneReader = definePermissionSet({ + name: EVERYONE_SET, + label: 'E1 everyone reader', + objects: { [OBJ.unbound]: { allowRead: true } }, +}); + +const find = (key: keyof typeof OBJ): ApiEndpoint => ({ + name: `e1guest_find_${key.toLowerCase()}`, + path: `${BASE}/${key}`, + method: 'GET', + summary: `find ${key}`, + description: `anonymous find on ${OBJ[key]}`, + type: 'object_operation', + objectParams: { object: OBJ[key], operation: 'find' }, + authRequired: false, + rateLimit: ARMED, +} as ApiEndpoint); + +const endpoints: ApiEndpoint[] = [ + find('private'), + find('publicRead'), + find('publicReadExternal'), + find('publicReadWrite'), + find('parent'), + find('child'), + find('unbound'), + find('fold'), + { + name: 'e1guest_create_public_rw', + path: `${BASE}/publicReadWrite`, + method: 'POST', + summary: 'create', + description: 'anonymous create', + type: 'object_operation', + objectParams: { object: OBJ.publicReadWrite, operation: 'create' }, + authRequired: false, + rateLimit: ARMED, + } as ApiEndpoint, +]; + +const intakeData = { provider: 'object' as const, object: OBJ.intake }; +const IntakeViews = defineView({ + list: { label: 'Intake', type: 'grid', data: intakeData, columns: [{ field: 'name' }] }, + formViews: { + intake: { + type: 'simple', + data: intakeData, + sections: [{ name: 'intake', label: 'Intake', columns: 1, fields: [{ field: 'name', required: true }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${FORM_SLUG}` }, + }, + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.guest-grants-channel', + namespace: 'e1guest', + version: '0.0.0', + type: 'app', + name: 'Guest Grants Channel Fixture', + description: 'ADR-0138 E1: the guest anchor bindings, the guest organization, at the class-4 door.', + }, + objects, + views: [IntakeViews], + permissions: [GuestReader, FoldSet, EveryoneReader], + apis: endpoints, +} as any); + +type Ctx = Record | undefined; + +let stack: VerifyStack; +let tempDir: string; +let adminToken: string; +let memberToken: string; +let ql: IObjectQLEngine; +let organizationId: string; +let parentId: string; +/** The context each engine `find` / `insert` on a fixture object was handed, in order. */ +const engineCalls: Array<{ method: string; object: string; context: Ctx }> = []; + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'os-e1-guest-')); + const artifactPath = join(tempDir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(fixtureStack)); + stack = await bootStack(fixtureStack, { + extraPlugins: [ + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); + adminToken = await stack.signIn(); + memberToken = await stack.signUp('e1guest-member@example.test', 'e1guest-member-pw-7311'); + + ql = stack.kernel.getService('objectql'); + const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 5, ...SYS }); + expect(orgs, 'the fixture boots the single-organization deployment D4 derives from').toHaveLength(1); + organizationId = String(orgs[0].id); + + // Two rows per object, written by the admin through the data door. + for (const name of [OBJ.private, OBJ.publicRead, OBJ.publicReadExternal, OBJ.publicReadWrite, OBJ.parent, OBJ.unbound, OBJ.fold]) { + for (const n of [1, 2]) { + const res = await stack.apiAs(adminToken, 'POST', `/data/${name}`, { name: `${name}-seed-${n}` }); + expect(res.status, await res.clone().text()).toBe(201); + if (name === OBJ.parent && n === 1) parentId = ((await res.json()) as { id: string }).id; + } + } + for (const n of [1, 2]) { + const res = await stack.apiAs(adminToken, 'POST', `/data/${OBJ.child}`, { name: `${OBJ.child}-seed-${n}`, parent: parentId }); + expect(res.status, await res.clone().text()).toBe(201); + } + + const watched = new Set(Object.values(OBJ)); + for (const [method, at] of [['find', 1], ['insert', 2]] as const) { + const original = (ql as any)[method].bind(ql); + vi.spyOn(ql as any, method).mockImplementation(async (...args: any[]) => { + if (watched.has(args[0])) engineCalls.push({ method, object: args[0], context: args[at]?.context }); + return original(...args); + }); + } +}, 180_000); + +afterAll(async () => { + vi.restoreAllMocks(); + await stack?.stop(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); +}); + +/** The engine's registry seam — not part of the engine slot's contract, so it is typed here, narrowly. */ +interface RegistrySeam { + registry: { registerItem(type: string, item: Record, key: string): void }; +} + +/** Declare a definition of `position` in the registry's bare slot — where an environment definition answers first. */ +function bindAnchor(position: 'guest' | 'everyone', permissionSets: string[]): void { + (ql as unknown as RegistrySeam).registry.registerItem('position', { name: position, label: position, permissionSets }, 'name'); +} + +async function anonymous(key: keyof typeof OBJ, init?: RequestInit): Promise<{ status: number; body: any; text: string }> { + const res = await stack.api(`/apps/e1guest/${key}`, init ?? { method: 'GET' }); + const text = await res.text(); + let body: any; + try { body = JSON.parse(text); } catch { body = undefined; } + return { status: res.status, body, text }; +} + +/** + * The rows a class-4 `find` endpoint answered. The answer's SHAPE is asserted, + * never defaulted: a zero-row pin must not pass on a body it failed to read. + */ +function rowsOf(body: any): any[] { + const records = body?.data?.records; + expect(Array.isArray(records), JSON.stringify(body)).toBe(true); + return records; +} + +/** The permission-set names the security service resolves for the context the engine was last handed. */ +async function resolvedSetNames(context: Ctx): Promise { + const security = stack.kernel.getService('security'); + const resolve = security.resolvePermissionSetsForContext; + expect(resolve, 'the security service serves the set resolution').toBeTypeOf('function'); + const sets = await resolve!.call(security, (context ?? {}) as SecurityContext); + return sets.map((s) => String(s.name)).sort(); +} + +function lastContext(object: string): Ctx { + return [...engineCalls].reverse().find((c) => c.object === object)?.context; +} + +describe('ADR-0138 E1 — nothing bound: the guest is denied at the class-4 door (D3 item 2)', () => { + it('a guest with no binding is refused 403 PERMISSION_DENIED, carrying the guest identity and no set', async () => { + const res = await anonymous('publicRead'); + expect(res.status, res.text).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(res.text).not.toContain(`${OBJ.publicRead}-seed-`); + const ctx = lastContext(OBJ.publicRead); + expect(ctx).toMatchObject({ principalKind: 'guest', positions: ['guest'], isSystem: false }); + expect(ctx).not.toHaveProperty('userId'); + }); +}); + +describe('ADR-0138 E1 — one channel: the name fold, the baseline and `everyone` never reach the guest (D3 item 3)', () => { + it('a set NAMED like the anchor is not folded into the guest\'s request', async () => { + const res = await anonymous('fold'); + expect(res.status, res.text).toBe(403); + expect(res.text).not.toContain(`${OBJ.fold}-seed-`); + // Negative control: the set is real and grants — to the admin, by name. + const admin = await stack.apiAs(adminToken, 'GET', `/data/${OBJ.fold}`); + expect(admin.status).toBe(200); + }); + + it('the guest resolves neither the baseline nor the `everyone` anchor\'s sets, while a member resolves both', async () => { + bindAnchor('everyone', ['member_default', EVERYONE_SET]); + try { + const res = await anonymous('unbound'); + expect(res.status, res.text).toBe(403); + const names = await resolvedSetNames(lastContext(OBJ.unbound)); + expect(names).not.toContain('member_default'); + expect(names).not.toContain(EVERYONE_SET); + // Negative control: a signed-in member is admitted to the same object, and + // it is `everyone`'s binding that admits it — the only set naming the object. + const member = await stack.apiAs(memberToken, 'GET', `/data/${OBJ.unbound}`); + expect(member.status, await member.clone().text()).toBe(200); + const memberNames = await resolvedSetNames(lastContext(OBJ.unbound)); + expect(memberNames).toContain(EVERYONE_SET); + expect(memberNames).toContain('member_default'); + } finally { + bindAnchor('everyone', ['member_default']); + } + }); +}); + +describe('ADR-0138 E1 — a bound read set grants that object only (D3 item 1, D4)', () => { + beforeAll(() => bindAnchor('guest', [GUEST_SET])); + + it('the guest envelope carries the bound set and the derived organization, and resolves exactly that set', async () => { + const res = await anonymous('publicRead'); + expect(res.status, res.text).toBe(200); + const ctx = lastContext(OBJ.publicRead); + expect(ctx).toMatchObject({ principalKind: 'guest', positions: ['guest'], isSystem: false, tenantId: organizationId }); + expect(ctx?.permissions).toEqual([GUEST_SET]); + expect(ctx).not.toHaveProperty('userId'); + expect(await resolvedSetNames(ctx)).toEqual([GUEST_SET]); + }); + + it('an object the bound set does not name stays denied', async () => { + const res = await anonymous('unbound'); + expect(res.status, res.text).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(res.text).not.toContain(`${OBJ.unbound}-seed-`); + }); + + it('a create the set grants lands in the derived organization, with no owner', async () => { + const res = await anonymous('publicReadWrite', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'e1guest-anonymous-write' }), + }); + expect(res.status, res.text).toBe(201); + const rows = await ql.find(OBJ.publicReadWrite, { where: { name: 'e1guest-anonymous-write' }, limit: 2, ...SYS }); + expect(rows).toHaveLength(1); + expect(rows[0].organization_id).toBe(organizationId); + expect(rows[0].owner_id ?? null).toBeNull(); + }); +}); + +describe('ADR-0138 E1 — the guest\'s row scope is share links only, under every sharing model (D3 item 6, ruling C)', () => { + beforeAll(() => bindAnchor('guest', [GUEST_SET])); + + // [ADR-0138 D3 item 6, as the maintainer's ruling C amends it — comment + // 6148183570 on #23266] The guest never takes the OWD branch of the + // row-level pipeline: not `sharingModel`, and not `externalSharingModel` + // (which no runtime reads for anyone, #23268). It owns nothing (D1), so it + // reaches rows only through share links. A guest with a bound read set and + // no link is ADMITTED to each object (200: the set grants read) and reads + // ZERO rows of it, whatever the object's sharing model — the `public_read` + // object that also declares `externalSharingModel: 'public_read'` included. + // The link control is the next block. + const cases: Array = ['private', 'publicRead', 'publicReadExternal', 'publicReadWrite', 'parent', 'child']; + for (const key of cases) { + it(`${OBJ[key]}: the guest is admitted and reads 0 rows`, async () => { + const res = await anonymous(key); + expect(res.status, res.text).toBe(200); + expect(rowsOf(res.body)).toHaveLength(0); + // Control: the rows are there, and the object serves them — to the admin. + const admin = await stack.apiAs(adminToken, 'GET', `/data/${OBJ[key]}`); + expect(admin.status).toBe(200); + const adminRows = ((await admin.json()) as { records?: unknown[] }).records; + expect(adminRows?.length).toBeGreaterThan(0); + }); + } +}); + +describe('ADR-0138 E1 — a share link still serves the guest its one record (ruling C closes the OWD branch, not links)', () => { + const ELIGIBLE = `${OBJ.publicRead}-seed-1`; + const idOf = async (name: string): Promise => + String((await ql.find(OBJ.publicRead, { where: { name }, limit: 1, ...SYS }))[0].id); + + it('the guest redeems a link and reads the linked record, on an object whose rows it otherwise reads none of', async () => { + const id = await idOf(ELIGIBLE); + const minted = await stack.apiAs(adminToken, 'POST', '/share-links', { object: OBJ.publicRead, recordId: id }); + expect(minted.status, await minted.clone().text()).toBe(201); + const { token } = ((await minted.json()) as { data: { token: string } }).data; + + const resolved = await stack.api(`/share-links/${encodeURIComponent(token)}/resolve`); + const text = await resolved.text(); + expect(resolved.status, text).toBe(200); + expect(JSON.parse(text).data.record.id).toBe(id); + + // Eligibility is held at every redemption: a record that stops qualifying + // stops being served through the link already minted for it. + await ql.update(OBJ.publicRead, { id, name: `${ELIGIBLE}-withdrawn` }, SYS); + try { + const after = await stack.api(`/share-links/${encodeURIComponent(token)}/resolve`); + const afterText = await after.text(); + expect(after.status, afterText).toBe(404); + expect(JSON.parse(afterText).error?.code).toBe('INVALID_OR_EXPIRED'); + } finally { + await ql.update(OBJ.publicRead, { id, name: ELIGIBLE }, SYS); + } + }); + + it('eligibility is held at the mint: a record that does not qualify gets no link', async () => { + const res = await stack.apiAs(adminToken, 'POST', '/share-links', { + object: OBJ.publicRead, + recordId: await idOf(`${OBJ.publicRead}-seed-2`), + }); + const text = await res.text(); + expect(res.status, text).toBe(422); + expect(JSON.parse(text).error?.code).toBe('RECORD_NOT_ELIGIBLE'); + }); +}); + +describe('ADR-0138 E1 — the form doors are unchanged (D4 item 4)', () => { + it('an anonymous form submission answers 201 and lands in the organization it always did', async () => { + const res = await stack.api(`/forms/${FORM_SLUG}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'e1guest-form-row' }), + }); + const text = await res.text(); + expect(res.status, text).toBe(201); + const rows = await ql.find(OBJ.intake, { where: { name: 'e1guest-form-row' }, limit: 2, ...SYS }); + expect(rows).toHaveLength(1); + expect(rows[0].organization_id).toBe(organizationId); + }); +}); + +describe('ADR-0138 E1 — a multi-organization deployment answers the located refusal (D4 items 1, 5)', () => { + beforeAll(async () => { + bindAnchor('guest', [GUEST_SET]); + await ql.insert('sys_organization', { name: 'E1 Second Organization', slug: 'e1guest-second' }, SYS); + }); + + it('the class-4 door refuses with NOT_IMPLEMENTED, naming the missing site binding, and serves nothing', async () => { + const res = await anonymous('publicRead'); + expect(res.status, res.text).toBe(501); + expect(res.body?.error?.code).toBe('NOT_IMPLEMENTED'); + expect(String(res.body?.error?.message)).toMatch(/site binding/); + expect(res.text).not.toContain(`${OBJ.publicRead}-seed-`); + }); + + it('the form doors keep their own answer on the same deployment, never the guest refusal', async () => { + const res = await stack.api(`/forms/${FORM_SLUG}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'e1guest-form-row-multi' }), + }); + const text = await res.text(); + // The reading before E1, on this same topology (measured at 197fbe2c04): the + // form door carries no organization, and the engine refuses an + // organization-less insert where the organization is not derivable + // (ADR-0131 D9). D4 item 4 keeps the form doors as they are: the guest's + // refusal does not reach them, because the form grant is not the guest. + expect(res.status, text).toBe(500); + expect(JSON.parse(text).code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + }); +}); + +describe('ADR-0138 E1 — the anchor\'s definition has no authoring door (measured)', () => { + it('the metadata door refuses a save of the packaged `guest` position', async () => { + const res = await stack.apiAs(adminToken, 'PUT', '/meta/position/guest', { + name: 'guest', + label: 'Guest', + permissionSets: [GUEST_SET], + }); + const text = await res.text(); + // Measured: the built-in anchor is packaged metadata, sealed against an + // in-place edit (ADR-0131; `position` takes no environment overlay). + expect(res.status, text).toBe(403); + expect(JSON.parse(text).code).toBe('NOT_OVERRIDABLE'); + }); +}); + +/** + * What a stack declares that would reach the guest: a `guest` position naming + * sets (the anchor's binding — a package is refused the built-in name at the + * package door, so this is expected empty by construction), and a set NAMED + * `guest` (the fold's only route, which E1 closes for the guest). + */ +function guestReach(stack: { positions?: unknown; permissions?: unknown }): { anchorSets: string[]; foldSets: string[] } { + const positions = (Array.isArray(stack.positions) ? stack.positions : []) as Array<{ name?: string; permissionSets?: string[] }>; + const permissions = (Array.isArray(stack.permissions) ? stack.permissions : []) as Array<{ name?: string }>; + return { + anchorSets: positions.filter((p) => p?.name === 'guest').flatMap((p) => p.permissionSets ?? []), + foldSets: permissions.filter((p) => p?.name === 'guest').map((p) => String(p.name)), + }; +} + +describe('ADR-0138 E1 — the bindings that start granting, counted on the reference apps', () => { + const apps: Array<[string, { positions?: unknown; permissions?: unknown }]> = [ + ['app-showcase', showcaseStack as any], + ['app-crm', crmStack as any], + ['app-multi-package', multiPackageStack as any], + ]; + for (const [name, stack] of apps) { + it(`${name}: no set starts granting to the guest, and no fold grant stops`, () => { + expect(guestReach(stack)).toEqual({ anchorSets: [], foldSets: [] }); + }); + } + + it('control: the census counts a stack that declares a set named like the anchor', () => { + expect(guestReach(fixtureStack as any)).toEqual({ anchorSets: [], foldSets: ['guest'] }); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index c570e6e4cd8..2a2219ca19e 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -16,7 +16,8 @@ * server, so the two entry points can never drift on authorization again. * * Resolves for every request it can ANSWER — anonymous requests yield the - * guest envelope (`{ isSystem: false, positions: [], permissions: [] }`) — + * guest envelope (`principalKind: 'guest'`, `positions: ['guest']`, + * `isSystem: false`, carrying the `guest` anchor's sets, ADR-0138 D3) — * and throws `AuthzStoreUnavailableError` (503) for the one class of fault * that leaves the answer undetermined: an authorization INPUT that exists and * could not be read (a permission-store read that failed, commit 6a180e42d; a `tenancy` @@ -224,6 +225,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise