diff --git a/.changeset/no-active-organization-write-refusal.md b/.changeset/no-active-organization-write-refusal.md new file mode 100644 index 0000000000..d16f57c0de --- /dev/null +++ b/.changeset/no-active-organization-write-refusal.md @@ -0,0 +1,61 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(security): a tenant-scoped write with no active organization is refused, naming what is missing (ADR-0123 D2, #8247/#8208) + + + +**BREAKING** for one caller state, in the direction of refusing what used to +corrupt silently: an authenticated, non-system caller with **no active +organization** writing to a tenant-scoped object under a walled tenancy posture +(`isolated` / `group`) now receives `403 PERMISSION_DENIED` instead of a `2xx`. + +### What was happening + +The Layer 0 write-side wall validated **supplied** `organization_id` values +only. That is the correct guard for a payload naming *another* tenant, and it +left the opposite case open: a payload naming *no* tenant, written by a caller +who *has* no tenant. Nothing filled it downstream either — auto-stamping lives +in the enterprise organizations runtime and has nothing to stamp when the caller +carries no active organization. + +So the row landed with `organization_id` NULL, and the read wall — correctly, +by the same posture — then hid it from every reader, including the author who +had just created it. A write that succeeds and a record nobody can reach. + +### The rule now (ADR-0123) + +Under an authenticated session with no active organization: + +- tenant-scoped **reads resolve to nothing** (Layer 0's deny sentinel — + unchanged, silent, HTTP 200); +- tenant-scoped **`insert` / `update` are refused loudly**, and the message + **names the missing active organization** rather than reading as a generic + permission denial; +- no path stamps a NULL tenant on behalf of an authenticated caller. + +`delete` is deliberately unaffected: it places no row and decides no tenant, so +its target is selected through the Layer 0 row wall, which already resolves to +nothing under this state. + +### Who is unaffected + +Reads. System contexts (boot seeding, reconcilers, backfills, imports). True +platform operators on a posture-permitting object (ADR-0095 D3) — and only +there: the same operator on an ordinary business tenant object meets the wall +like anyone else. The `single` posture, where there is no wall at all. Objects +that opted out of tenancy or carry no `organization_id` column. Federated +objects whose tenant anchor is a phantom. Under `group`, a caller with a +non-empty membership set is fully scoped and unaffected even with no active +organization, because membership — not the active organization — is that +posture's scope. + +### If you hit the refusal + +The caller genuinely has no organization to write into. Give them a membership +(or an active organization selection) and retry; the refusal names this so it is +not mistaken for a permission-set problem. Deployments that reached this state +at sign-up are additionally addressed by the membership-ordering fix in +`@objectstack/plugin-auth`, which settles the membership before the first +session mints. diff --git a/docs/adr/0123-no-active-organization-session-semantics.md b/docs/adr/0123-no-active-organization-session-semantics.md new file mode 100644 index 0000000000..6ea6bdcb75 --- /dev/null +++ b/docs/adr/0123-no-active-organization-session-semantics.md @@ -0,0 +1,87 @@ +# ADR-0123: An authenticated session with no active organization is a legal state — reads resolve to nothing, tenant-scoped writes are refused loudly + +**Status**: Accepted (2026-08-13) +**Deciders**: ObjectStack Protocol Architects (maintainer ruling on [#8247](https://github.com/objectstack-ai/objectstack/issues/8247), 2026-08-13) +**Builds on**: [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (D1 — Layer 0 is an independent, always-first, AND-composed tenant filter; W1/W2), [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (D1/D2 — the posture spectrum and the `group` union wall; "empty/absent scope → deny"), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (D1/D2 — the membership reconciler as the single owner of the "every user gets a membership" invariant, and the `session.create.before` hook that resolves `activeOrganizationId` from `sys_member`), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (D3 — the closed error-code vocabulary this refusal draws from rather than extending), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared wall the write path does not meet is the defect this record closes) +**Consumers**: `@objectstack/plugin-security` (`security-plugin.ts` step 3.7, `tenant-layer.ts`), `@objectstack/plugin-auth` (`auth-manager.ts` `composeDatabaseHooks`, `reconcile-membership.ts`), `@objectstack/plugin-sharing` (`sharing-rule.ts` — already conforming), `@objectstack/plugin-audit` (`auth-session-audit.ts` login/logout rows) +**Surfaced by**: [#8247](https://github.com/objectstack-ai/objectstack/issues/8247), from three independently measured cards — [#8158](https://github.com/objectstack-ai/objectstack/issues/8158) (fell open), [#8208](https://github.com/objectstack-ai/objectstack/issues/8208) (fell closed, silently), [#8245](https://github.com/objectstack-ai/objectstack/issues/8245) (wrote rows no reader can see) + +--- + +## TL;DR + +One state — **an authenticated session whose `activeOrganizationId` is null** — produced three different, mutually contradictory behaviours in three subsystems, all measured on the same working day. None of the three implementations was individually wrong: the platform had never declared what the state *means*, so each layer improvised. + +This ADR declares it. The state is **legal**, and it has **fail-closed semantics**: + +- **Tenant-scoped reads resolve to nothing.** Already true, and it stays true — Layer 0's deny sentinel, not an error. +- **Tenant-scoped writes are refused loudly** — a 4xx whose message **names the missing active organization**. A write that cannot say which tenant its row belongs to does not land. +- ⛔ **No silent NULL stamping, anywhere.** A row whose tenant column is NULL *because the caller had no organization to supply* is a row its own author cannot read back; that outcome is now unreachable, in every subsystem — no layer gets an exemption. (D3 states the scope: this is about the caller's missing organization, not a promise that every tenant-scoped row is stamped — stamping belongs to the runtime that activates the walled posture in the first place.) +- **The audit ledger is the one carve-out, and it is paid for by ordering, not by an exemption** (D3): membership settles before the first session mints, so the account-creation rows a bare refusal would permanently lose are written *with* a tenant instead of being refused or NULL-stamped. + +## Context + +### The state is structurally guaranteed, and cannot be defined away + +`session.create.before` derives a session's `activeOrganizationId` from the caller's `sys_member` row (ADR-0093; `AuthManager.composeDatabaseHooks` → `defaultActiveOrg`). The membership itself is written by the ADR-0093 reconciler composed into `user.create.after`, and better-auth **defers that past the signup transaction**. So every new user's first session predates their membership and legitimately carries no active organization. + +Signup is not the only producer. A member removed from their organization, an `invite-only` deployment before the invite lands, an SSO JIT user pending placement, and a multi-organization deployment whose reconciler binds nobody (ADR-0093 D1 `no-target-org`) all reach the same state. **Eliminating it at the mint point (option A of #8247) cannot close the class** — which is why this record declares the state rather than outlawing it. + +### The three improvisations + +| Card | Subsystem | Behaviour under the state | Consequence | +|:--|:--|:--|:--| +| #8158 | `plugin-sharing` `adminOrgScope` | fell **open** | an org-scoped `manage_sharing` holder read and wrote every tenant's sharing rules | +| #8208 | Layer 0 wall + the write path | fell **closed, silently** | an HTTP-created record was stamped `organization_id: NULL` and was immediately invisible to its own creator | +| #8245 | audit ledger | wrote rows **no reader can ever see** | every audit row from a user's first session carried a NULL tenant, permanently invisible to RLS readers | + +#8158's fix (PR #8237) already chose *refuse, naming the missing organization*, over *answer empty*, and gave its reason: `manage_sharing` is declared `scope: 'org'`, so with no organization there is no scope in which it grants anything. **That reasoning generalizes, and this ADR is the generalization.** #8158's landed fix conforms and stands unchanged. + +### The asymmetry that produced #8208 + +The read path and the write path met the same missing value and drew opposite conclusions. + +`computeTenantLayer0Filter` (`tenant-layer.ts`) already fails closed on the read side: a walled posture on a tenant object with no organization scope yields `RLS_DENY_FILTER` — zero rows, no error. That is correct and is not changed here. + +The write path never met the wall at all. The Layer 0 write-side twin (`security-plugin.ts` step 3.7) validated **supplied** `organization_id` values only — its own comment says so: *"This validates SUPPLIED values only; it never fills an absent one."* That gate exists to catch a **forged** tenant (ADR-0095 / ADR-0105 D5), and a payload that supplies nothing has nothing to forge, so an ordinary insert walked straight past it. Auto-stamping lives in the enterprise `@objectstack/organizations` runtime, and it too has nothing to stamp when the caller has no active organization. + +So both halves were individually defensible and jointly produced a write that succeeds and a record nobody — including its author — can read. **If a write is allowed to proceed without an organization, something has to be able to read the result back.** Nothing can. Therefore the write must not proceed. + +## Decision + +**D1 — The state is legal and named.** "Authenticated, with no active organization" is a declared session state, not an illegal intermediate to be eliminated at the mint point. Every subsystem that meets it inherits the semantics below instead of inventing a fourth. + +**D2 — Tenant-scoped reads resolve to nothing; tenant-scoped writes are refused loudly.** + +- *Reads* keep Layer 0's deny sentinel: zero rows, HTTP 200, no error. Unchanged from ADR-0095 D1 / ADR-0105 D1. An empty result set is the honest answer to "show me my organization's rows" when the caller has no organization. +- *Writes that place or move a row* — `insert` and `update` — are **refused**, with `PERMISSION_DENIED` / HTTP 403 and a message that **names the missing active organization**. The code comes from ADR-0112's standard catalog rather than a new registration: the condition is a permission-class refusal, and ADR-0112 D3 directs a generic condition to the catalog instead of a synonym. This is the same code and status PR #8237 put on the wire for the same state, which is what makes "#8158 conforms" a measured statement rather than an assertion. +- *`delete`* places nothing and decides no tenant. Its target is selected through the Layer 0 row wall, which already resolves to nothing — so a delete under this state matches no row and is governed by the read rule, deliberately. This boundary is stated so the next author does not read its absence as an oversight. + +**The scope of the refusal is exactly the state, and no wider.** It fires only for an authenticated, non-system caller, on an object the posture actually walls (a tenant object under `isolated`/`group`; never a `tenancy.enabled:false` platform-global object, never an object with no `organization_id` column, never a federated phantom anchor), who is not a platform operator crossing the wall by ADR-0095 D3. System contexts — boot seeding, reconcile hooks, backfills, imports — short-circuit the security middleware entirely and are untouched. Under `group`, "no organization scope" means an **empty membership set**, matching ADR-0105 D2's own fail-closed rule. + +**D3 — ⛔ No silent NULL stamping, and the ledger carve-out is paid for by ordering.** Under the state this record governs — an authenticated caller with **no active organization** — no path may write a tenant-scoped row with a NULL tenant and report success, in any subsystem; D2's refusal is what makes that hold, and no layer gets an exemption from it. + +*Scope of that guarantee, stated so it is not read as wider than it is.* It is about the **caller's missing organization**, not a promise that every tenant-scoped row is stamped. Auto-stamping belongs to the enterprise `@objectstack/organizations` runtime, which is also what **activates** every walled posture — so a real walled deployment has the stamper by construction, and an org-bound caller's row is stamped there. A deployment that reaches a walled posture *without* it (`multiTenant: 'posture-only'`, the harness mode) still lands `organization_id: null` for a caller who **has** an organization. That is pre-existing and deliberate, and the stamping site already says why: keeping the stamp in that runtime means a forged `org-scoping` registration "yields NULL-org rows that the wall hides, i.e. a broken deployment rather than a working unlicensed one". D2 does not reach that case and is not meant to — the caller has an organization, so there is nothing missing for a refusal to name. This record neither changes that property nor endorses it; it declines to restate a packaging decision as a security guarantee the open packages cannot keep alone. + +For the audit ledger the naive application of D2 would be *worse* than the defect: refusing to write an account-creation row loses history permanently, and nothing back-fills a ledger row that was never written. So the ledger is not exempted — the **ordering** is fixed instead: + +> The ADR-0093 membership reconciler settles **synchronously, before the first session's active organization is resolved**, rather than only in the deferred `user.create.after` hook. The first session therefore mints *with* its organization, and the login row it produces carries a tenant. + +The reconciler is idempotent, yields to any pre-existing membership, and never throws (ADR-0093 D2), so hoisting it to the session seam adds a settle point, not a second owner. Where the reconciler legitimately binds nobody — `invite-only`, or a multi-organization deployment with no unambiguous target org — the session still mints with no active organization, and that is D1's legal state working as declared. **The ordering fix must not manufacture a membership that policy says must not exist**; it removes a race, not a policy. + +**D4 — The refusal names what is missing.** A 403 whose message says only "access denied" is indistinguishable from every other 403 and sends the reader to look at permissions, which are fine. The refusal states that the caller has **no active organization** and that a tenant-scoped write requires one. This is the ADR-0049 half of the record: a declared wall whose refusal cannot be told apart from an unrelated denial is a wall nobody can act on. + +## Consequences + +**What changes.** An authenticated caller with no active organization, writing to a walled tenant object, now gets `403 PERMISSION_DENIED` naming the missing organization where they previously got `2xx` and an unreadable row. This is a **wire-visible behaviour change** on a security boundary; it is breaking in the direction of refusing something that used to silently corrupt. + +**What does not change.** Reads. System contexts. Platform operators. Single-posture deployments (no wall, Layer 0 inert). Objects that opted out of tenancy. The supplied-`organization_id` forge guard, which keeps its own semantics and its own message. #8158's landed fix. + +**Dispositions this record carries.** #8208 becomes the D2 refusal — the silent NULL stamp is unreachable. #8245 becomes the D3 ordering fix — the first session mints with its organization, so its ledger rows carry a tenant. + +**What was rejected.** + +- *Option A — outlaw the state at the mint point.* Cannot close the class: the non-signup producers (member removed, invite-only, JIT pending, multi-org with no target) still reach it, and it puts a synchronous dependency on the signup hot path for a state that would still need semantics afterwards. +- *Answer the write with an empty success, or a 404.* Both are the #7676 shape one layer over: a truthful-looking answer to a question that was never really answered. A write is not a query; there is no honest empty result for it. +- *Let the read path admit org-less rows to their owner.* It would make "no active organization" mean "a private tenant of one", inventing a fourth tenancy posture nobody declared, and it would hand the same rows to any future reader whose own organization is null. +- *Register a new error code for the condition.* ADR-0112 D3 sends generic (permission-class) conditions to the standard catalog; a synonym would put two spellings of one refusal on the wire, and would make #8158's landed fix retroactively non-conforming. diff --git a/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts b/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts new file mode 100644 index 0000000000..eab245aa02 --- /dev/null +++ b/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts @@ -0,0 +1,365 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0123 D2 / #8247, #8208] An authenticated session with NO active + * organization: reads resolve to nothing, tenant-scoped writes are REFUSED. + * + * ## What was open + * + * The Layer 0 write-side twin (`security-plugin.ts` step 3.7) validated + * **supplied** `organization_id` values only — its own comment says so. That is + * the right guard for a payload naming ANOTHER tenant (#2937 / ADR-0105 D5) and + * it leaves the opposite case wide open: a payload naming NO tenant, written by + * a caller who HAS no tenant. Nothing downstream fills it either (auto-stamping + * lives in the enterprise organizations runtime and has nothing to stamp), so + * the row landed with `organization_id` NULL and the read wall then hid it from + * every reader — including the author who had just created it (#8208). + * + * ## Anti-vacuity — the two traps this file is built around + * + * **1. "Reads resolve to nothing" and "the bug" look identical from outside.** + * A test asserting an empty result set passes under BOTH the correct + * fail-closed rule and the silent-NULL defect. So the read-side cases here never + * assert "empty". They take ONE concrete row, and assert that the SAME row is + * *admitted* under a system context and *excluded* under the org-less caller's + * own composed filter — and that the excluding filter is the DENY SENTINEL + * specifically, not an absent policy. Empty-by-rule and empty-by-accident are + * different verdicts here, and a row that was never written would be admitted by + * neither, so the system-context leg is what makes the discrimination real. + * + * **2. A 4xx assertion on the status alone cannot tell a conforming refusal + * from any other 4xx.** ADR-0123 D2/D4 require the refusal to NAME the missing + * active organization, so every write-side case pins three things — `code`, + * `statusCode`, and the message content — never a bare `rejects.toThrow()`. The + * ordering case is the sharpest of them: a caller with no organization scope who + * ALSO supplies a foreign `organization_id` satisfies the sibling forge guard + * too, and both answer 403 `PERMISSION_DENIED`. Only the message tells them + * apart, which is exactly why D4 is a decision and not a nicety. + * + * Every control below is paired with the case it controls: for each exemption + * (system context, platform operator, non-tenant object, `single` posture, a + * non-empty `group` membership set) there is a sibling case differing in that + * one fact where the refusal DOES fire. A file of controls alone would pass on + * the pre-fix build. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { matchesFilterCondition } from '@objectstack/formula'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; + +/** Plain CRUD, NO row-level policies — so Layer 0 is the only enforcer under test. */ +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +/** + * A platform operator: the superuser bit AND a platform-EXCLUSIVE capability. + * Both halves are required by ADR-0095 D3 — an `organization_admin` holds the + * superuser bit through its wildcard grant and must NOT cross the wall. + */ +const PLATFORM_ADMIN: PermissionSet = { + name: 'admin_full_access', + label: 'Platform Administrator', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } }, + systemPermissions: ['manage_platform_settings', 'manage_metadata'], +} as unknown as PermissionSet; + +const TENANT_SCHEMA = { + name: 'task', + fields: { id: { name: 'id' }, organization_id: { name: 'organization_id' }, owner_id: { name: 'owner_id' }, name: { name: 'name' } }, +}; + +interface BootOpts { + schema?: Record; + sets?: PermissionSet[]; + /** + * Which set a caller carrying no positions resolves to. The harness has no + * position→set binding, so this is how a case chooses its persona: the + * default `member_default` is the ordinary member, `admin_full_access` + * selects {@link PLATFORM_ADMIN}. + */ + fallback?: string; + /** Override the posture; omitted → the `org-scoping` sentinel, i.e. `isolated`. */ + posture?: string; + /** Leave the wall OFF entirely (`single`) — no `org-scoping` service at all. */ + noWall?: boolean; + findOneImpl?: (query: unknown) => unknown; +} + +async function boot(opts: BootOpts = {}) { + const schema = opts.schema ?? TENANT_SCHEMA; + const sets = opts.sets ?? [MEMBER]; + let middleware: ((opCtx: unknown, next: () => Promise) => Promise) | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: never) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + findOne: vi.fn(async (_o: string, q: unknown) => (opts.findOneImpl ? opts.findOneImpl(q) : null)), + }, + metadata: { get: async () => schema, list: async () => sets }, + }; + if (!opts.noWall) services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + if (opts.posture) services['tenancy'] = { posture: opts.posture }; + const warn = vi.fn(); + const ctx = { + logger: { info: vi.fn(), warn, error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: opts.fallback ?? 'member_default' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.init(ctx as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.start(ctx as any); + return { + plugin, + warn, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readFilter: (object: string, context: any) => (plugin as any).getReadFilter(object, context), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + run: async (opCtx: any) => { await middleware!(opCtx, async () => {}); return opCtx; }, + }; +} + +/** Authenticated, ordinary member, and NO active organization — the whole subject. */ +const ORG_LESS = { userId: 'u1', positions: [], permissions: [] }; +/** The same person one `sys_member` row later. The single-fact control. */ +const ORG_BOUND = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; + +/** + * Assert a refusal CONFORMS to ADR-0123 D2/D4 rather than merely being some 4xx. + * Three facts, because any two of them are satisfied by refusals this rule is + * not: the sibling forge guard is also `PERMISSION_DENIED`/403, and any 403 at + * all satisfies the first two. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function expectConformingRefusal(err: any, opts: { principal?: 'session' | 'delegator' } = {}) { + expect(err, 'the write was not refused at all').toBeDefined(); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode).toBe(403); + // D4: the refusal NAMES what is missing. Without this the message is + // indistinguishable from a permission denial, and sends the reader to audit + // permission sets that are perfectly fine. + expect(err.message).toMatch(/no active organization/i); + expect(err.message).toMatch(/organization to place the record in/i); + // NOT the sibling forge guard's sentence — that one is about a value the + // caller supplied, which is not why this write cannot land. + expect(err.message).not.toMatch(/another tenant/i); + if (opts.principal === 'delegator') expect(err.message).toMatch(/delegating principal/i); + // The operator half names the decision so the next reader can find it. + expect(err.developerMessage).toMatch(/ADR-0123 D2/); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function refusalFrom(run: (opCtx: any) => Promise, opCtx: unknown): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await run(opCtx as any); + return undefined; + } catch (e) { + return e; + } +} + +describe('[ADR-0123 D2] tenant-scoped WRITES with no active organization are refused loudly', () => { + it('insert is refused, and the refusal names the missing active organization', async () => { + const h = await boot(); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS }, + }); + expectConformingRefusal(err); + }); + + it('update is refused the same way', async () => { + // Pre-image visible so step 2.7 passes and 3.7 is genuinely reached. + const h = await boot({ findOneImpl: () => ({ id: 't1', organization_id: 'org-1' }) }); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'update', data: { id: 't1', name: 'renamed' }, context: { ...ORG_LESS }, + }); + expectConformingRefusal(err); + }); + + it('a BULK insert is refused as a whole — no partial landing', async () => { + const h = await boot(); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'insert', data: [{ name: 'A' }, { name: 'B' }], context: { ...ORG_LESS }, + }); + expectConformingRefusal(err); + }); + + it('CONTROL: the same write by the same user WITH an active organization lands, and is still not stamped', async () => { + // The single-fact control, and the one that makes every case above mean + // something: only `tenantId` differs. It also pins that the refusal is NOT + // a new stamping behaviour — SecurityPlugin still never fills + // `organization_id` (ADR-0105 D5/D12); it refuses when it cannot be filled. + const h = await boot(); + const opCtx = { object: 'task', operation: 'insert', data: { name: 'A' } as Record, context: { ...ORG_BOUND } }; + await h.run(opCtx); + expect(opCtx.data.organization_id).toBeUndefined(); + }); + + it('CONTROL: a system context is untouched (boot seeding, reconcilers, backfills, imports)', async () => { + const h = await boot(); + await h.run({ object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS, isSystem: true } }); + }); + + it('CONTROL: the `single` posture has no wall, so an org-less write is ordinary', async () => { + const h = await boot({ noWall: true }); + await h.run({ object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS } }); + }); + + it('CONTROL: a true PLATFORM operator crosses the wall on a posture-permitting object (ADR-0095 D3)', async () => { + // `access.default: 'private'` is one of the postures that PERMITS the + // crossing; the object still carries `organization_id`, so it is a tenant + // object and the wall is live for everyone else — which the sibling case + // below measures on the very same fixture. + const h = await boot({ + schema: { ...TENANT_SCHEMA, access: { default: 'private' } }, + sets: [PLATFORM_ADMIN], + fallback: 'admin_full_access', + }); + await h.run({ object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS } }); + }); + + it('…but the SAME platform operator IS refused on an ordinary BUSINESS tenant object', async () => { + // The control's control, and the half that matters: ADR-0095 D3's exemption + // is POSTURE-scoped, never a property of the person. Drop the posture and + // the identical caller — same superuser bits, same platform capabilities — + // meets the wall like everyone else. Without this pin, the case above would + // pass just as well if the exemption had been widened to "any platform + // admin, any object", which is the W2 hole ADR-0095 exists to keep shut. + const h = await boot({ sets: [PLATFORM_ADMIN], fallback: 'admin_full_access' }); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS }, + }); + expectConformingRefusal(err); + }); + + it('CONTROL: an object that opted OUT of tenancy is not tenant-scoped, so nothing is refused', async () => { + const h = await boot({ schema: { ...TENANT_SCHEMA, tenancy: { enabled: false } } }); + await h.run({ object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS } }); + }); + + it('CONTROL: an object with no `organization_id` column is not tenant-scoped either', async () => { + const h = await boot({ schema: { name: 'task', fields: { id: { name: 'id' }, name: { name: 'name' } } } }); + await h.run({ object: 'task', operation: 'insert', data: { name: 'A' }, context: { ...ORG_LESS } }); + }); + + it('`delete` is deliberately NOT refused here — it places no row (ADR-0123 D2 boundary)', async () => { + // Stated as a pin so the boundary is a decision on the record rather than an + // omission the next author "fixes". A delete decides no tenant; its target + // is selected through the Layer 0 ROW wall, which already resolves to + // nothing under this state — the read rule, applied to the targeting step. + const h = await boot({ findOneImpl: () => ({ id: 't1', organization_id: 'org-1' }) }); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'delete', options: { where: { id: 't1' } }, context: { ...ORG_LESS }, + }); + expect(err?.message ?? '').not.toMatch(/no active organization/i); + }); + + describe('the `group` posture reads MEMBERSHIP, not the active organization (ADR-0105 D2)', () => { + it('an EMPTY membership set is refused', async () => { + const h = await boot({ posture: 'group' }); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'insert', data: { name: 'A' }, + context: { ...ORG_LESS, accessible_org_ids: [] }, + }); + expectConformingRefusal(err); + }); + + it('CONTROL: a NON-EMPTY membership set lands, even with no active organization', async () => { + // The half a `tenantId`-only test would get wrong: under `group` the + // membership set IS the scope, so this caller is fully scoped despite + // carrying no active organization. + const h = await boot({ posture: 'group' }); + await h.run({ + object: 'task', operation: 'insert', data: { name: 'A' }, + context: { ...ORG_LESS, accessible_org_ids: ['org-1', 'org-2'] }, + }); + }); + }); + + describe('ordering against the sibling forge guard (both are 403 PERMISSION_DENIED)', () => { + it('no organization scope AND a foreign organization_id → the no-active-organization sentence wins', async () => { + // Both guards would fire. Only one of the two messages is actionable: the + // supplied value is not why this write cannot land — the caller has no + // organization scope at all, so NO value could have satisfied the wall. + const h = await boot(); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'insert', data: { name: 'A', organization_id: 'org-2' }, context: { ...ORG_LESS }, + }); + expectConformingRefusal(err); + }); + + it('CONTROL: WITH an organization scope, a foreign organization_id still gets the forge sentence', async () => { + // The other half of the ordering: the new gate must not swallow the guard + // it was placed in front of. + const h = await boot(); + const err = await refusalFrom(h.run, { + object: 'task', operation: 'insert', data: { name: 'A', organization_id: 'org-2' }, context: { ...ORG_BOUND }, + }); + expect(err?.code).toBe('PERMISSION_DENIED'); + expect(err?.message).toMatch(/would place .* in another tenant/); + expect(err?.message).not.toMatch(/no active organization/i); + }); + }); +}); + +describe('[ADR-0123 D2] tenant-scoped READS with no active organization resolve to nothing — BY RULE', () => { + /** + * One concrete row, in a real tenant. It exists throughout; nothing below + * deletes it or writes a different one. That is what makes "the read returns + * nothing" attributable to the RULE rather than to an empty store. + */ + const ROW = { id: 't1', organization_id: 'org-1', owner_id: 'u1', name: 'A' }; + + it('the org-less caller\'s composed read filter is the DENY SENTINEL, and it excludes the row', async () => { + const h = await boot(); + const filter = await h.readFilter('task', { ...ORG_LESS }); + // Not merely "some filter": the sentinel specifically. A missing policy + // would be `undefined` (unrestricted) and an ordinary wall would be + // `organization_id = …`; both are different verdicts from this one. + expect(filter).toEqual(RLS_DENY_FILTER); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(matchesFilterCondition(ROW as any, filter as any)).toBe(false); + }); + + it('the SAME row is admitted under a system context — so the empty read is by RULE, not by accident', async () => { + // The discriminator the card asks for. If the row simply did not exist, this + // leg would be just as empty as the one above and the pair would prove + // nothing. It is admitted here and excluded there, on the same object, in + // the same boot. + const h = await boot(); + const filter = await h.readFilter('task', { isSystem: true, userId: 'sys', positions: [], permissions: [] }); + expect(filter).toBeUndefined(); // no scoping imposed at all + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(matchesFilterCondition(ROW as any, (filter ?? {}) as any)).toBe(true); + }); + + it('CONTROL: the same caller WITH an active organization gets a real wall that ADMITS the row', async () => { + // The third leg. It rules out "plugin-security denies this object to + // everyone but system": one `sys_member` row turns the sentinel into + // `organization_id = org-1`, which the row satisfies. + const h = await boot(); + const filter = await h.readFilter('task', { ...ORG_BOUND }); + expect(filter).toEqual({ organization_id: 'org-1' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(matchesFilterCondition(ROW as any, filter as any)).toBe(true); + }); + + it('a read is NOT refused — reads resolve to nothing, only writes are refused (the D2 asymmetry)', async () => { + // The asymmetry is the decision, so it gets its own pin: making reads throw + // would break every list screen for a user between organizations. + const h = await boot(); + const opCtx = { object: 'task', operation: 'find', options: { where: {} }, context: { ...ORG_LESS } }; + await h.run(opCtx); // must not throw + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0834e900a8..a6bac37dc2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -58,6 +58,7 @@ import { hasPhantomTenantAnchor } from './federated-phantom-anchors.js'; import { normalizeTenancyPosture, postureEnforcesWall, + postureUsesUnionScope, type TenancyPosture, } from '@objectstack/spec/security'; import { @@ -287,6 +288,54 @@ interface RlsFilterOptions { */ const AUTHORED_ROW_WRITE_PROBE_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; +/** + * [ADR-0123 D2] Does this execution context carry ANY organization scope the + * tenant wall could enforce with? + * + * The answer is posture-shaped, and that is the whole reason this is a function + * rather than an `if (!ctx.tenantId)` at the call site: under `isolated` the + * scope is the ACTIVE organization (`tenantId`), under `group` it is the + * caller's MEMBERSHIP SET (`accessible_org_ids`) and the active organization no + * longer bounds anything (ADR-0105 D2). Asking only about `tenantId` would + * refuse every `group` caller who is legitimately scoped by membership; asking + * only about the set would let an `isolated` caller through on a set the + * `isolated` wall never reads. + * + * This is a cheap PRE-TEST, never the verdict. It exists so the common paths — + * the `single` posture, and any caller who does have an organization — skip the + * layered RLS compile entirely. The verdict is always + * {@link isTenantWallDenial} over the Layer 0 filter, which is the one place + * that knows about platform-admin exemptions, non-tenant objects and phantom + * anchors. + */ +function callerHasOrganizationScope(context: any, posture: TenancyPosture): boolean { + if (postureUsesUnionScope(posture)) { + const orgIds = context?.accessible_org_ids; + return Array.isArray(orgIds) && orgIds.length > 0; + } + const tenantId = context?.tenantId; + return tenantId != null && tenantId !== ''; +} + +/** + * [ADR-0123 D2] Is this Layer 0 filter the fail-closed DENY sentinel — i.e. "a + * walled posture on a tenant object, and the context carries no organization + * scope to enforce with"? + * + * Identity is decided on the sentinel's own value, not on shape: `RLS_DENY_FILTER` + * is a single `id` equality against a string no record can carry, and + * `computeTenantLayer0Filter` SPREADS it (`{ ...RLS_DENY_FILTER }`) rather than + * returning the frozen object, so a reference check would silently answer `false` + * for every real denial. Every other Layer 0 return is a real tenant predicate + * (`organization_id = …` / `organization_id $in […]`) or `null`, so no legitimate + * wall can collide with this test. + */ +function isTenantWallDenial(filter: Record | null | undefined): boolean { + if (!filter) return false; + const keys = Object.keys(filter); + return keys.length === 1 && keys[0] === 'id' && filter.id === RLS_DENY_FILTER.id; +} + const SYSTEM_ROW_PROVENANCE: Record< string, { noun: string; pluralNoun: string; managed: Record } @@ -2103,6 +2152,94 @@ export class SecurityPlugin implements Plugin { !!r && typeof r === 'object' && !Array.isArray(r), ); + // ── [ADR-0123 D2] NO ACTIVE ORGANIZATION → refuse the write, loudly ── + // + // The forge guard below validates SUPPLIED values only, and says so. It + // is the right shape for what it guards (a payload naming ANOTHER + // tenant), but it leaves the opposite case wide open: a payload naming + // NO tenant, written by a caller who HAS no tenant. Nothing downstream + // fills it either — auto-stamping lives in the enterprise + // `@objectstack/organizations` runtime and has nothing to stamp when the + // caller carries no active organization. So the row landed with + // `organization_id` NULL, and the read wall then hid it from EVERY + // reader including the author who had just created it: a write that + // succeeds and a record nobody can reach. + // + // Both halves were individually defensible, which is why this sat. The + // rule that resolves them (ADR-0123 D2, from the #8247 ruling): under an + // authenticated session with no active organization, tenant-scoped READS + // resolve to nothing (Layer 0's deny sentinel — unchanged, below in + // `computeTenantLayer0Filter`) and tenant-scoped WRITES are REFUSED, + // with a message that NAMES the missing active organization. If a write + // cannot say which tenant its row belongs to, it does not land. + // + // The verdict is DERIVED, never re-derived: `computeWriteTenantCheckFilter` + // is the same Layer 0 the read side computes, so every exemption stays in + // one place — a `tenancy.enabled:false` platform-global object, an object + // with no `organization_id` column, a federated phantom anchor (#7835), a + // true PLATFORM_ADMIN on a posture-permitting object (ADR-0095 D3), and + // the whole `single` posture all yield `null` here and are untouched. + // System / boot writes never reach this line at all (`isSystem` + // short-circuits the middleware). Under `group`, "no organization scope" + // means an EMPTY membership set — ADR-0105 D2's own fail-closed rule. + // + // The cheap posture/context pre-test comes first on purpose: the common + // deployment is `single` (Layer 0 inert), and the common caller HAS an + // active organization. Neither pays for the layered compile below. + // + // Ordered AHEAD of the forge guard deliberately. A caller with no + // organization scope at all who also supplies a foreign `organization_id` + // would satisfy both; "you have no active organization" is the actionable + // half — the supplied value is not the reason the write cannot land. + // `delete` is deliberately absent: it places no row and decides no + // tenant, so its target is selected through the Layer 0 ROW wall, which + // already resolves to nothing (ADR-0123 D2, stated there as a boundary + // rather than left as an omission). + if (this.orgScopingEnabled && !callerHasOrganizationScope(opCtx.context, this.tenancyPosture)) { + const callerWall = await this.computeWriteTenantCheckFilter( + permissionSets, + opCtx.object, + opCtx.operation, + opCtx.context, + ); + // [ADR-0090 D10] The delegator is walled on its own context, exactly as + // the forge guard walls it — an on-behalf-of write may not land a row + // the delegator itself could not place. + const delegatorWall = + delegatorSets && !callerHasOrganizationScope(delegatorContext, this.tenancyPosture) + ? await this.computeWriteTenantCheckFilter( + delegatorSets, + opCtx.object, + opCtx.operation, + delegatorContext, + ) + : null; + const denied = isTenantWallDenial(callerWall) + ? 'caller' + : isTenantWallDenial(delegatorWall) + ? 'delegator' + : null; + if (denied) { + const principal = denied === 'delegator' ? 'the delegating principal' : 'this session'; + this.logger.warn?.( + `[Security] Layer 0 tenant wall REFUSED ${opCtx.operation} '${opCtx.object}' — ` + + `${principal} has no active organization to place the record in (ADR-0123 D2, fail-closed)`, + ); + throw new PermissionDeniedError( + `[Security] Access denied: '${opCtx.object}' is scoped to an organization, and ` + + `${principal} has no active organization — so this ${opCtx.operation} has no ` + + `organization to place the record in. Join or select an active organization and retry.`, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + + `organization: tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', and ` + + `${denied === 'delegator' ? "the delegator's" : "the caller's"} context resolved neither ` + + `\`tenantId\` nor a non-empty \`accessible_org_ids\`. Reads under this state resolve to nothing; ` + + `writes are refused rather than landing a row with a NULL organization that no reader — ` + + `including its own author — could ever see. System contexts and platform operators are unaffected.`, + ); + } + } + const suppliedRows = writeRows.filter( (r) => r.organization_id != null && r.organization_id !== '', ); diff --git a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts index 308b21dbee..a09c47e6f2 100644 --- a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts @@ -81,6 +81,8 @@ const STAMPED = 'showcase_ext_customer'; const LOCAL = 'showcase_private_note'; /** A row that really exists in the remote `customers` table (fixture seed). */ const REMOTE_ID = 'c1'; +/** The organization the harness admin is bound to (see the `beforeAll` note). */ +const ADMIN_ORG = 'org_8119_harness'; const SYS = { isSystem: true } as ExecutionContext; @@ -141,6 +143,44 @@ describe('[#8119] federated phantom anchor: single-record gates + share posture' }); await registrar.syncObjectSchema(FEDERATED); + // [ADR-0123 D2] Bind the harness admin to an organization BEFORE the first + // sign-in. This stack boots `posture-only`, i.e. the Layer 0 wall is ACTIVE, + // and under a walled posture the open default-org bootstrap deliberately + // abstains — so nothing binds this admin and their session resolves with no + // active organization. Tenant-scoped writes are refused in that state + // (a 403 naming the missing organization), which the control note below is: + // the fixture used to get a 2xx and a row stamped `organization_id: null` + // that its own creator could not read back — #8208, which the refusal closes. + // + // Done by hand because there is no harness answer: `bootStack`'s + // `orgContext` option REFUSES to compose with `multiTenant` (under a walled + // posture it would be a no-op that reads like a feature — the #7762 vacuity + // class), so an org-bound caller here has to be built explicitly. + // + // This is fixture SETUP, not the subject: every assertion in this file is + // about the federated phantom anchor and share posture, and none of them + // reads the admin's organization. The bind exists so the setup step can + // reach the behaviour under test at all. The refusal itself is measured + // deliberately, on its own scenario, in + // `no-active-organization-write-refusal.dogfood.test.ts`. + { + const adminUser = (await ql.find('sys_user', { + where: { email: 'admin@objectos.ai' }, limit: 1, context: SYS, + })) as unknown; + const adminRow = (Array.isArray(adminUser) ? adminUser[0] : undefined) as { id?: unknown } | undefined; + const adminUserId = String(adminRow?.id ?? ''); + expect(adminUserId, 'the seeded harness admin resolves').toBeTruthy(); + await ql.insert('sys_organization', { id: ADMIN_ORG, name: 'Harness Org', slug: ADMIN_ORG }, { context: SYS }); + await ql.insert( + 'sys_member', + { id: 'mem_8119_admin', organization_id: ADMIN_ORG, user_id: adminUserId, role: 'owner' }, + { context: SYS }, + ); + } + + // Signed in AFTER the membership exists: `session.create.before` resolves the + // active organization at MINT time, so a token taken earlier would not carry + // one however the store looks afterwards. adminToken = await stack.signIn(); adminCtx = await resolveAuthzContext({ ql, diff --git a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts new file mode 100644 index 0000000000..35475d0a65 --- /dev/null +++ b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0123 D2 / #8208] The HTTP-level proof that an authenticated caller with +// NO active organization cannot land a tenant-scoped row. +// +// ## Why this file exists — it makes a measurement DELIBERATE that arrived by +// accident +// +// #8208 was filed from a booted showcase stack: a seeded platform admin, whose +// resolved authz context carries no `tenantId` at all, created a record through +// the real HTTP path under an ACTIVE Layer 0 wall. The write answered 2xx, the +// row was stored with `organization_id: null`, and the read wall then hid it +// from every reader — including the admin who had just created it: +// +// POST /api/v1/data/showcase_private_note -> 2xx, row created +// stored row: { owner_id: 'PK07N5…', organization_id: null } +// GET /api/v1/data/showcase_private_note -> 200 {"records":[],"total":0} +// GET /api/v1/data/showcase_private_note/:id -> 404 +// +// The ADR-0123 D2 refusal closes that by refusing the write. When it landed, +// the FIRST evidence that it fires over real HTTP was a sibling dogfood fixture +// going red on its own setup step (`federated-phantom-share-grant`, whose +// `beforeAll` created exactly this control note as this exact caller). That is +// a real measurement, but it is a fragile place to keep one: the next author to +// touch that fixture would silently delete it, and a fixture that is green +// again carries no record of what it was red FOR. +// +// So the measurement moves here, as an assertion that exists to hold it. +// +// ## Anti-vacuity +// +// Three ways this file could pass while proving nothing, each closed by a pin: +// +// 1. **The wall might not be on.** Then every write is ordinary and a refusal +// would mean something else entirely. PRECONDITION 1 asserts the posture in +// force is a walled one. +// 2. **The admin might actually HAVE an organization.** Then the refusal under +// test could never fire and a green file would be measuring nothing. +// PRECONDITION 2 asserts the resolved context carries no `tenantId` — the +// exact fact #8208 reported, re-measured rather than assumed. +// 3. **The 403 might be any other 403.** `showcase_private_note` is an +// owner-private object behind a CRUD gate that also answers 403 +// `PERMISSION_DENIED`; a status-only assertion cannot tell them apart. So +// the refusal is pinned on its MESSAGE (it must name the missing active +// organization), and — the decisive leg — the SAME caller, on the SAME +// object, through the SAME route, SUCCEEDS once a `sys_member` row exists. +// One fact differs between the refusal and the success: the organization. +// +// @proof: no-active-organization-write-refusal + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { resolveAuthzContext } from '@objectstack/core'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +/** The owner-private LOCAL object #8208 measured on. */ +const LOCAL = 'showcase_private_note'; +const SYS = { isSystem: true } as ExecutionContext; +const ORG = 'org_8208_pin'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +describe('[ADR-0123 D2 / #8208] a tenant-scoped write with no active organization is refused over HTTP', () => { + let stack: VerifyStack; + let ql: IObjectQLEngine; + let adminToken: string; + let adminId: string; + /** The admin's tenant as the session resolved it BEFORE any membership. */ + let tenantBefore: unknown; + + beforeAll(async () => { + // `posture-only` is the mode #8208 measured on: it requests the `isolated` + // posture (the wall is ACTIVE) without the enterprise organizations runtime. + stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + ql = stack.kernel.getService('objectql'); + adminToken = await stack.signIn(); + + const adminCtx = await resolveAuthzContext({ + ql, + headers: new Headers({ authorization: `Bearer ${adminToken}` }), + getSession: async (h: unknown) => { + const authService = await stack.kernel.getServiceAsync<{ + api?: { getSession?(a: { headers: unknown }): Promise }; + getApi?(): Promise<{ getSession?(a: { headers: unknown }): Promise }>; + }>('auth'); + const api = authService?.api ?? (await authService?.getApi?.()); + return api?.getSession?.({ headers: h }); + }, + } as never) as ExecutionContext; + adminId = String(adminCtx.userId); + tenantBefore = (adminCtx as unknown as { tenantId?: unknown }).tenantId; + }, 180_000); + + afterAll(async () => { await stack?.stop?.(); }); + + // ── preconditions: both halves of the state, measured not assumed ──────── + + it('PRECONDITION: a real signed-in admin principal', () => { + expect(adminId, 'a real signed-in admin principal').toBeTruthy(); + }); + + it('PRECONDITION: the Layer 0 wall is ACTIVE on this boot', async () => { + // Asked of the `tenancy` service — the declared single source of truth for + // "what posture is this deployment in?" (ADR-0093 D4 / ADR-0105 D1), and + // the same fact Layer 0 switches on. A DEGRADED boot resolves to `single`, + // and under `single` Layer 0 contributes nothing at all: every case below + // would then be measuring an unwalled deployment where the refusal could + // never fire, and the file would be vacuous while green. + const tenancy = await stack.kernel.getServiceAsync<{ + posture: string; + isolationActive: boolean; + degraded: boolean; + }>('tenancy'); + expect(tenancy?.posture, 'tenancy posture in force').toBe('isolated'); + expect(tenancy?.isolationActive, 'the organization wall is actually enforced').toBe(true); + // `degraded` is the trap this pair exists to catch: a walled posture that + // was REQUESTED but cannot be enforced resolves to `single` and sets this, + // so asking only what was requested would let the file pass on a boot with + // no wall at all. + expect(tenancy?.degraded, 'the wall is not degraded').toBe(false); + }); + + it("PRECONDITION: the admin's session carries NO active organization — #8208's own fact", () => { + // Re-measured, not assumed. `posture-only` requests a walled posture, and + // the open default-org bootstrap deliberately abstains under every walled + // posture — so nothing binds this admin and nothing can stamp their session. + expect(tenantBefore ?? null, "the admin's resolved tenantId").toBeNull(); + }); + + // ── the refusal, over the real HTTP path ──────────────────────────────── + + it('POST is refused 403, and the refusal NAMES the missing active organization', async () => { + const res = await stack.apiAs(adminToken, 'POST', `/data/${LOCAL}`, { + title: '#8208 refusal pin', body: 'must not land', + }); + expect(res.status, 'the org-less write must be refused').toBe(403); + + const body = await res.json() as { error?: string; code?: string }; + // The ADR-0112 envelope, both halves — a status alone cannot distinguish a + // conforming refusal from any other 403 on this route. + expect(body.code).toBe('PERMISSION_DENIED'); + // ADR-0123 D4: the refusal states WHAT IS MISSING. Without this the message + // is indistinguishable from a permission denial and sends whoever is + // debugging to audit permission sets that are perfectly fine. + expect(body.error ?? '').toMatch(/no active organization/i); + expect(body.error ?? '').toMatch(/organization to place the record in/i); + }); + + it('and NOTHING was written — the refusal is not a 403 after the fact', async () => { + // #8208's defect was a write that SUCCEEDED and then hid. A refusal that + // still landed the row would reproduce it exactly while looking fixed, so + // the store is asked under a SYSTEM context, which no wall narrows. + const rows = rowsOf(await ql.find(LOCAL, { + where: { title: '#8208 refusal pin' }, limit: 5, context: SYS, + })); + expect(rows, 'no row may exist for the refused write').toHaveLength(0); + }); + + // ── the decisive control: ONE fact differs ────────────────────────────── + + it('CONTROL: the SAME caller, object and route SUCCEED once a membership exists', async () => { + // This is what makes the refusal above attributable to the missing + // organization rather than to this object's CRUD/ownership gates, which + // answer 403 PERMISSION_DENIED on the very same route. + // + // The membership is inserted by hand on purpose: `bootStack`'s `orgContext` + // option REFUSES to compose with `multiTenant` (it would be a no-op under a + // walled posture, which is the vacuity #7762 closed), so an org-bound caller + // under `posture-only` has no harness answer today. + await ql.insert('sys_organization', { id: ORG, name: 'Pin Org', slug: ORG }, { context: SYS }); + await ql.insert( + 'sys_member', + { id: 'mem_8208_pin', organization_id: ORG, user_id: adminId, role: 'owner' }, + { context: SYS }, + ); + + // A FRESH session: `session.create.before` resolves the active organization + // at mint time, so the existing token cannot pick the membership up. + const boundToken = await stack.signIn(); + const res = await stack.apiAs(boundToken, 'POST', `/data/${LOCAL}`, { + title: '#8208 control note', body: 'must land', + }); + expect(res.status, 'the org-bound write must land').toBeLessThan(300); + + const rows = rowsOf(await ql.find(LOCAL, { + where: { title: '#8208 control note' }, limit: 5, context: SYS, + })); + expect(rows, 'the control row exists').toHaveLength(1); + expect(rows[0]?.owner_id, 'owned by its creator').toBe(adminId); + }); +}); diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json index 54019a7a2c..2762c899a8 100644 --- a/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json +++ b/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json @@ -1,7 +1,8 @@ { "file": "packages/plugins/plugin-security/src/security-plugin.ts", "adrs": [ - "ADR-0106" + "ADR-0106", + "ADR-0123" ], - "invariant": "ADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this." + "invariant": "ADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this.\n\nADR-0123 D2 — an authenticated session with NO active organization is a legal state with fail-closed semantics, and the two sides are deliberately ASYMMETRIC: tenant-scoped reads resolve to nothing (Layer 0's deny sentinel, silent, HTTP 200) while tenant-scoped insert/update are REFUSED with a 403 whose message names the missing active organization. Step 3.7's no-active-organization refusal is that write half. Deleting it, or 'simplifying' it into the sibling forge guard, restores the state the ruling closed: the forge guard validates SUPPLIED organization_id values only, so a payload naming no tenant written by a caller having no tenant walks past it, lands with organization_id NULL, and is then hidden by the read wall from every reader including its own author. `delete` is absent on purpose (it places no row; the Layer 0 row wall already resolves its target to nothing), and the refusal must stay ordered AHEAD of the forge guard so a caller with no organization scope is told that, not that the value they supplied is foreign." }