From 16f5682f7149f31f5a91c8cfcd108eeca91aff91 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 14:09:59 +0000 Subject: [PATCH] fix(plugin-security): a public_read_write OWD opens row-level writes, not just the creator's (#8023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's wildcard `owner_only_writes` floor (object '*', operation update, `created_by == current_user.id`, positions ['org_member']) stayed composed into Layer 1 on objects declaring `sharingModel: 'public_read_write'`, because the by-id write pre-image gate only lets ISharingService REPLACE the floor on a positive `allow` and a public object makes the service abstain. Net effect: the OWD declared 'everyone reads and writes' and the runtime enforced 'only the creator writes' — a declared-but-unenforced security property on a published surface. The floor is now conditioned on the object's DECLARED OWD at collection time, before #7665's derive-from-select branch, so the write class falls through to the caller's select narrowing and by-id write visibility (#7792) is preserved. Only `update` is opened (owner_only_deletes survives), only the canonical `public_read_write` spelling qualifies (controlled_by_parent and an unset model on a system object do not), and only the PLATFORM's floor is dropped — an app-authored policy with the same predicate still reaches the compiler. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../owd-public-read-write-opens-row-writes.md | 50 +++ .../src/platform-ownership-policies.test.ts | 51 +++ .../src/platform-ownership-policies.ts | 73 +++ .../plugin-security/src/security-plugin.ts | 55 ++- ...lic-read-write-write-floor.dogfood.test.ts | 416 ++++++++++++++++++ 5 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 .changeset/owd-public-read-write-opens-row-writes.md create mode 100644 packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts diff --git a/.changeset/owd-public-read-write-opens-row-writes.md b/.changeset/owd-public-read-write-opens-row-writes.md new file mode 100644 index 0000000000..0c2396cc08 --- /dev/null +++ b/.changeset/owd-public-read-write-opens-row-writes.md @@ -0,0 +1,50 @@ +--- +"@objectstack/plugin-security": patch +--- + +fix(plugin-security): a `public_read_write` object is writable by everyone the access matrix grants `edit`, not only by each row's creator (#8023) + +An object declaring `sharingModel: 'public_read_write'` promised "everyone can +see and edit" and delivered "everyone can see, only the creator can edit". A +persona the access matrix grants `edit: true` could `GET` a row **200** and +`PATCH` the same row **403 PERMISSION_DENIED**, with the record-level refusal +("You do not have access to this record…"). Three declarations agreed the write +was allowed — the access matrix's `edit: true`, the object's OWD, and the +absence of any authored RLS — and the runtime refused it anyway. + +The cause is the platform's own row-level write ownership floor. +`member_default` ships `owner_only_writes` (object `'*'`, operation `update`, +`created_by == current_user.id`, positions `['org_member']`). The by-id write +pre-image gate lets `ISharingService`'s tri-state verdict **replace** that floor, +but only on a positive `allow` — and on a public object the service **abstains**, +because record sharing genuinely does not enforce there. An abstain keeps the +floor, so the floor became the object's only row-level write gate and quietly +overrode its OWD. + +An object whose author declared `public_read_write` now never inherits the +wildcard `update` floor in the first place. Three boundaries are deliberate: + +- **`delete` is unchanged.** `public_read_write` is "see and edit"; the legacy + `full` alias that also covered transfer/delete was refused a mechanical + conversion for being *wider* than it (ADR-0090 D4). `owner_only_deletes` still + refuses a non-creator delete. +- **Only the OWD that says so.** The declared model is read, never + `plugin-sharing`'s effective bucket — which folds `controlled_by_parent` and an + unset model on a system object into the same `'public'` value. A detail object + derives access from its master, and an unset model on a `sys_*` table is a + legacy default, so neither opens writes. An unresolvable schema fails closed. +- **Only the platform's floor.** Provenance decides, so an app-authored policy + spelling the identical predicate still reaches the compiler and still refuses + (ADR-0049). + +Because the floor is removed at collection time, the write class is then empty +and the derive-from-select scope supplies the write filter — so "you cannot +mutate what you cannot see" continues to hold on these objects: a caller +narrowed by select-only RLS still cannot write a row outside its readable set. + +Objects with any other OWD are untouched: on `private` and `public_read`, a +non-owner write is still refused. The object-level gate is untouched too — a +persona with `edit: false` still gets the object-level refusal, with its own +distinct sentence. `POST /api/v1/security/explain` follows the same composition, +so it stops reporting the `rls` layer as `narrows` for `update` on an object +with zero authored RLS, while continuing to report a narrowing for `delete`. diff --git a/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts b/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts index 72a61299c7..b9d8b454f4 100644 --- a/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts +++ b/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts @@ -12,7 +12,10 @@ import { describe, it, expect } from 'vitest'; import { isPlatformOwnershipFloorPolicy, platformOwnershipFloorPolicyCount, + owdDeclaresOpenRowWrites, + owdOpenWritesCoversOperation, OWNERSHIP_FLOOR_PREDICATE, + OWD_OPENING_ROW_WRITES, } from './platform-ownership-policies.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -95,3 +98,51 @@ describe('[#5492] platform ownership-floor provenance', () => { expect(writeClass.length).toBe(platformOwnershipFloorPolicyCount()); }); }); + +// [#8023] The OWD condition on the floor. The headline case is proven +// end-to-end over HTTP by +// `packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts`; +// what belongs HERE is the vocabulary boundary — which spellings open writes +// and which look like they might but must not. +describe('[#8023] owdDeclaresOpenRowWrites — the DECLARED model, never the effective bucket', () => { + it('the one canonical spelling opens writes, in both the flat and the nested spot', () => { + expect(owdDeclaresOpenRowWrites({ sharingModel: 'public_read_write' })).toBe(true); + expect(owdDeclaresOpenRowWrites({ security: { sharingModel: 'public_read_write' } })).toBe(true); + expect(OWD_OPENING_ROW_WRITES).toBe('public_read_write'); + }); + + it('every OWD that does NOT declare open writes keeps the floor', () => { + for (const model of ['private', 'public_read', 'controlled_by_parent']) { + expect(owdDeclaresOpenRowWrites({ sharingModel: model }), `${model} must keep the floor`).toBe(false); + } + }); + + it('⚠️ `controlled_by_parent` and an UNSET model on a system object must NOT open writes', () => { + // Both collapse into plugin-sharing's `'public'` bucket + // (`effectiveSharingModel`), which is why reading THAT here would have been + // the bug: `controlled_by_parent` derives access from its master (which has + // its own OWD and its own gate) and declares nothing about its own writers, + // while an unset model on a `sys_*` table is a legacy default rather than + // an author's statement — opening writes there would hand every member + // cross-creator writes on the platform's identity tables. + expect(owdDeclaresOpenRowWrites({ name: 'owdw_detail', sharingModel: 'controlled_by_parent' })).toBe(false); + expect(owdDeclaresOpenRowWrites({ name: 'sys_user_position', isSystem: true })).toBe(false); + expect(owdDeclaresOpenRowWrites({ name: 'sys_user_position' })).toBe(false); + }); + + it('an unresolvable or malformed schema fails CLOSED (the floor stays)', () => { + for (const schema of [null, undefined, {}, { sharingModel: null }, { sharingModel: 'read_write' }]) { + expect(owdDeclaresOpenRowWrites(schema)).toBe(false); + } + // `read_write` above is the ADR-0090 D4 LEGACY alias. It no longer parses at + // authoring, and a stored value the platform does not recognise must never + // be read as the wider posture. + }); + + it('the opened write class is `update` alone — `owner_only_deletes` survives', () => { + expect(owdOpenWritesCoversOperation('update')).toBe(true); + for (const operation of ['delete', 'select', 'insert', 'all']) { + expect(owdOpenWritesCoversOperation(operation), `${operation} must not be opened`).toBe(false); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/platform-ownership-policies.ts b/packages/plugins/plugin-security/src/platform-ownership-policies.ts index d5c610d0e7..0df3a9a9f4 100644 --- a/packages/plugins/plugin-security/src/platform-ownership-policies.ts +++ b/packages/plugins/plugin-security/src/platform-ownership-policies.ts @@ -101,3 +101,76 @@ export function isPlatformOwnershipFloorPolicy( export function platformOwnershipFloorPolicyCount(): number { return PLATFORM_OWNERSHIP_FLOOR_KEYS.size; } + +/** + * [#8023] The ONE Org-Wide Default that DECLARES row-level writes open, and the + * reason the floor above must not apply to an object carrying it. + * + * The floor is the platform's answer for objects whose OWD says nothing about + * writes. `public_read_write` is not such an object: the author has declared + * "everyone can see and edit" (`spec/security/sharing.zod.ts`), the access + * matrix grants `edit: true`, and the runtime was nevertheless refusing every + * write by a non-creator — the OWD contract stated and unenforced, which is the + * one thing a declaration must never be. + * + * ## Why this is read here and not deferred to `ISharingService` + * + * The composition already asks the sharing service for a tri-state write + * verdict, and on this object it answers `abstain` — correctly: record sharing + * genuinely does not enforce on a public object. But `abstain` is ONE answer + * covering three different facts (public OWD / no owner field / bypass-listed + * internal), and only the first says anything about writes being open. #5492's + * E2 experiment measured what collapsing all three into "permitted" costs: an + * ordinary member's cross-creator UPDATE on an `owner_id`-less object turned + * 403 into 200. So the abstain's meaning is left exactly as it is, and the OWD + * — which is a DECLARATION about this object rather than a verdict about this + * row — is read directly, on the security side, where the floor is composed. + * + * ## The DECLARED model, never the effective one + * + * `plugin-sharing`'s `effectiveSharingModel` folds `public_read_write`, + * `controlled_by_parent` and an unset model on a system object into one + * `'public'` bucket. That bucket is right for "does record sharing enforce + * here"; it is wrong for this question, and reading it here would open writes + * on two classes that never declared them: + * + * - `controlled_by_parent` derives its access from the MASTER record, which + * has its own OWD and its own gate (`assertControlledByParentWrite`) — the + * detail declares nothing about who may write it; + * - an UNSET model on a `sys_*` / `isSystem` object is a legacy default, not + * an author's statement. Opening writes there would hand every member + * cross-creator writes on the platform's own identity tables — the shape of + * the objectui#2348 incident that made an unset model fail closed in the + * first place. + * + * So the test is EXPLICIT equality with the one canonical spelling. An + * unresolvable schema yields `false` and the floor stays (fail closed). + */ +export const OWD_OPENING_ROW_WRITES = 'public_read_write'; + +/** + * True iff this object's author DECLARED the org-wide-open write baseline. + * Reads both the flat and the nested spot, exactly as every other OWD reader + * in the platform does. + */ +export function owdDeclaresOpenRowWrites(schema: unknown): boolean { + const s = schema as { sharingModel?: unknown; security?: { sharingModel?: unknown } } | null; + const model = s?.sharingModel ?? s?.security?.sharingModel; + return model === OWD_OPENING_ROW_WRITES; +} + +/** + * The write class {@link owdDeclaresOpenRowWrites} opens — `update` and nothing + * else, so `owner_only_deletes` survives on a `public_read_write` object. + * + * This is the OWD vocabulary's own boundary, not a conservative guess: + * `public_read_write` is defined as "everyone can see and edit", and the legacy + * `full` alias that ALSO covered transfer/delete was refused a mechanical + * conversion precisely because it is "wider than `public_read_write`" + * (`spec/conversions/registry.ts`, ADR-0090 D4). An object that wants + * cross-creator deletes declares it with an app-authored policy or a share, + * both of which reach the compiler untouched. + */ +export function owdOpenWritesCoversOperation(operation: string): boolean { + return operation === 'update'; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b874e374ea..4f3c249ba0 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -49,7 +49,11 @@ import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; -import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; +import { + isPlatformOwnershipFloorPolicy, + owdDeclaresOpenRowWrites, + owdOpenWritesCoversOperation, +} from './platform-ownership-policies.js'; import { hasPhantomTenantAnchor } from './federated-phantom-anchors.js'; import { normalizeTenancyPosture, @@ -177,6 +181,18 @@ interface ObjectSecurityMeta { * real remote `organization_id` — both keep the tenant wall exactly as it is. */ tenantAnchorIsPhantom: boolean; + /** + * [#8023] The object DECLARES `sharingModel: 'public_read_write'` — the one + * OWD that states row-level writes are open org-wide. Read from the author's + * declaration, never from `plugin-sharing`'s effective bucket; see + * `owdDeclaresOpenRowWrites` in `platform-ownership-policies.ts` for why the + * two must not be confused here. + * + * `false` when the schema does not resolve, so the floor stays (fail closed) + * — and `unresolved` meta is never cached, so a transient boot miss is + * retried rather than frozen into an over-strict answer. + */ + owdOpensRowWrites: boolean; requiredPermissions: NormalizedRequiredPermissions; fieldRequiredPermissions: Record; /** @@ -3995,6 +4011,41 @@ export class SecurityPlugin implements Plugin { let layer1: Record | null = null; if (!(posturePermits && superuserBypass)) { let collected = this.collectRLSPolicies(permissionSets, object, operation, (context?.positions ?? []) as string[]); + // [#8023] An object whose author DECLARED `sharingModel: + // 'public_read_write'` does not inherit the platform's wildcard `update` + // ownership floor AT ALL. QA #7637 measured the contradiction it caused + // on the stock showcase: `showcase_project` declares that OWD, the access + // matrix grants `showcase_contributor` `edit: true`, no RLS is authored — + // and a PATCH of a row the persona had not created answered 403 with the + // record-level sentence. The floor is `member_default`'s + // `owner_only_writes` (object `'*'`, `created_by == current_user.id`, + // positions `['org_member']`), and it survived because the by-id gate + // only lets `ISharingService` REPLACE it on a positive `allow`, while a + // public object makes the service `abstain`. + // + // ⚠️ THE PLACEMENT IS THE FIX, not just where it reads best. Removing the + // floor HERE — before the #7665 derive-from-select branch below — leaves + // the update class EMPTY, so that branch then derives the write scope + // from the caller's SELECT narrowing and "you cannot mutate what you + // cannot see" still holds on this very object (#7792's by-id + // write-visibility property, which the card names as a non-regression + // criterion). Removing it at the `dropPlatformOwnershipFloor` filter + // further down would instead compile Layer 1 to null and hand back an + // UNGATED by-id write — the same hole #7665 closed, reopened by a fix for + // a different bug. + // + // Scope, stated twice because each half is load-bearing: + // - only the PLATFORM's floor is dropped (provenance, ADR-0105 D3): an + // app-authored policy spelling the identical predicate still reaches + // the compiler and still refuses (ADR-0049); + // - only `update` (`owdOpenWritesCoversOperation`): `owner_only_deletes` + // stays, because `public_read_write` is "see and edit" and the alias + // that also meant delete was removed for being wider than it. + // Layer 0 (the tenant wall) is untouched — a `public_read_write` object is + // org-wide open, never cross-tenant open. + if (meta.owdOpensRowWrites && owdOpenWritesCoversOperation(operation)) { + collected = collected.filter((p) => !isPlatformOwnershipFloorPolicy(p)); + } // [#7665] The write-visibility floor: a write target must be inside the // caller's READABLE set. When NO policy of the write class applies to // this (principal, object, operation) — nothing authored for the class, @@ -4580,6 +4631,8 @@ export class SecurityPlugin implements Plugin { // [#7835] Federated object carrying the registry's INJECTED // `organization_id` — a column the platform provisions no storage for. tenantAnchorIsPhantom: hasPhantomTenantAnchor(obj), + // [#8023] The author's DECLARED OWD, not the effective sharing bucket. + owdOpensRowWrites: owdDeclaresOpenRowWrites(obj), requiredPermissions: normalizeRequiredPermissions((obj as any)?.requiredPermissions), fieldRequiredPermissions, unresolved: !obj, diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts new file mode 100644 index 0000000000..46e91148fc --- /dev/null +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -0,0 +1,416 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#8023] A `public_read_write` OWD must actually open row-level WRITES. +// +// ── the measured symptom ────────────────────────────────────────────────── +// QA run #7637's matrix re-drive found exactly one failing cell of 124: a +// `showcase_contributor` that the access matrix grants `edit: true` on +// `showcase_project` (OWD `public_read_write`, zero authored RLS) could GET a +// seeded row 200 and PATCH it 403 — with the RECORD-level sentence, not the +// object-level one. +// +// ── the mechanism ───────────────────────────────────────────────────────── +// `member_default` ships the platform's row-level write ownership floor +// (`owner_only_writes`, object `'*'`, operation `update`, +// `created_by == current_user.id`, positions `['org_member']` — the seed lives +// in `plugin-security/src/objects/default-permission-sets.ts`). The by-id +// write pre-image gate lets `ISharingService`'s tri-state verdict REPLACE that +// floor, but only on `allow`; on a `public_read_write` object the service +// ABSTAINS (record sharing does not enforce there at all), and an abstain +// KEEPS the floor. Net effect: the OWD declared "everyone reads and writes" +// and the runtime enforced "only the creator writes". +// +// ── the fix this file pins ──────────────────────────────────────────────── +// The floor is now conditioned on the object's OWD at COLLECTION time: an +// object that explicitly declares `sharingModel: 'public_read_write'` never +// inherits the wildcard `update` floor in the first place. Two consequences +// this file measures rather than assumes: +// +// - because the floor leaves the update class EMPTY, #7665's +// derive-from-select then supplies the write scope, so "you cannot mutate +// what you cannot see" still holds on the very same object (case D); +// - the DELETE floor is untouched. `public_read_write` is "everyone can see +// and edit" (`spec/security/sharing.zod.ts`); the legacy `full` alias that +// also covered transfer/delete was removed for having no lossless target +// precisely because it is WIDER than `public_read_write` +// (`spec/conversions/registry.ts`). Case E pins that boundary. +// +// ── why this file is HTTP-level ─────────────────────────────────────────── +// The harm is an HTTP `PATCH` answering 403. A unit assertion about a compiled +// filter is supporting evidence for the mechanism; it is not evidence that the +// symptom is gone. Every acceptance case below drives the real REST stack. +// +// ── the ONE difference between the three objects ────────────────────────── +// `owdw_open` / `owdw_read` / `owdw_secret` are byte-identical apart from +// their `sharingModel`. Whatever separates their verdicts is the OWD and +// nothing else — the same discriminator idiom as +// `authored-row-write-scope.dogfood.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { defineStack, definePermissionSet } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { resolveAuthzContext } from '@objectstack/core'; +import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +// ── the three objects, identical but for the OWD ─────────────────────────── + +const OPEN = 'owdw_open'; // public_read_write — writes DECLARED open +const READ = 'owdw_read'; // public_read — reads open, owner writes +const SECRET = 'owdw_secret'; // private — owner reads and writes + +const commonFields = () => ({ + title: Field.text({ label: 'Title', required: true, maxLength: 160 }), + body: Field.text({ label: 'Body', maxLength: 2000 }), + stage: Field.text({ label: 'Stage', maxLength: 40 }), + owner_id: Field.lookup('sys_user', { label: 'Owner' }), +}); + +const mk = (name: string, sharingModel: string) => + ObjectSchema.create({ + name, + label: `OWD Write ${name}`, + pluralLabel: `OWD Write ${name}s`, + sharingModel: sharingModel as never, + fields: commonFields(), + }); + +/** + * The persona the access matrix grants `edit: true` — the card's + * `showcase_contributor`. Holds the object-level bits on all three objects and + * authors NO row-level policy: every row-level verdict it gets comes from the + * platform baseline plus the OWD, exactly as on the showcase. + * + * `allowDelete` is granted DELIBERATELY on `owdw_open` so case E's refusal is + * unambiguously the row-level delete floor rather than a missing object bit. + */ +const EditorSet = definePermissionSet({ + name: 'owdw_editor', + label: 'OWD Write — object-level read+edit (the matrix says edit:true)', + objects: { + [OPEN]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + [READ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + [SECRET]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, +}); + +/** + * The OBJECT-level control — the card's `member_default` persona: reads the + * object, `allowEdit: false`. Its refusal must keep its own distinct sentence, + * because "the object gate refused you" and "the row gate refused you" are + * different facts with different remedies. + */ +const ViewerSet = definePermissionSet({ + name: 'owdw_viewer', + label: 'OWD Write — object-level read only (edit:false)', + objects: { + [OPEN]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + }, +}); + +/** + * The #7792 control: the SAME object-level grant as the editor, plus a + * SELECT-ONLY narrowing. Dropping the write floor must not resurrect the by-id + * write-visibility hole — a caller still may not write a row outside the set + * it can read. + */ +const ScopedSet = definePermissionSet({ + name: 'owdw_scoped', + label: 'OWD Write — read+edit with a select-only narrowing', + objects: { + [OPEN]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + }, + rowLevelSecurity: [ + { name: 'owdw_scoped_open_rows', object: OPEN, operation: 'select', using: "stage == 'open'" }, + ], +}); + +const probeApp = defineStack({ + manifest: { + id: 'com.example.owdwritefloor', + namespace: 'owdw', + version: '0.0.1', + type: 'app', + name: 'OWD public_read_write Write Floor Probe', + engines: { protocol: '^17' }, + }, + objects: [mk(OPEN, 'public_read_write'), mk(READ, 'public_read'), mk(SECRET, 'private')], + permissions: [EditorSet, ViewerSet, ScopedSet], +}); + +const SYS = { isSystem: true } as const; +const EN = BUILTIN_OPERATION_MESSAGES.en!; +const RECORD_SENTENCE = EN.record_access_denied!; +const OBJECT_SENTENCE = EN.permission_denied!; + +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; + +describe('[#8023] a public_read_write OWD opens row-level writes (and nothing else)', () => { + let stack: VerifyStack; + let ql: any; + let aliceToken: string; // creates every row — the `created_by` the floor keys on + let bobToken: string; // edit:true, created nothing + let mallyToken: string; // edit:false — the object-level control + let carolToken: string; // edit:true + select-only narrowing — the #7792 control + let aliceId: string; + let bobId: string; + + /** row ids, per object */ + const rows: Record = {}; + + const patchAs = (token: string, object: string, id: string, body: Record) => + stack.apiAs(token, 'PATCH', `/data/${object}/${id}`, body); + + const envelopeOf = async (res: any) => { + try { return (await res.json()) as any; } catch { return null; } + }; + + /** The SAME authz context the REST entry point builds — never a hand-rolled principal. */ + const authzFor = async (token: string) => { + const authService: any = await stack.kernel.getServiceAsync('auth'); + let api: any = authService?.api; + if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); + const headers = new Headers({ authorization: `Bearer ${token}` }); + return resolveAuthzContext({ + ql, + headers, + getSession: async (h: any) => api?.getSession?.({ headers: h }), + }); + }; + + beforeAll(async () => { + stack = await bootStack(probeApp, { + // ⚠️ LOAD-BEARING, and measured: the platform write floor is + // positions-gated to `org_member`, which a principal only holds through a + // `sys_member` row (`resolve-authz-context.ts` maps the membership role). + // An org-LESS harness gives a fresh sign-up `positions: ['everyone']`, the + // floor never applies, and case A passes on the BROKEN build — the fixture + // would be green over the defect it exists to pin. `orgContext: true` + // stands up the default organization the membership reconciler binds new + // users to (ADR-0093 D1), which is the shape a real deployment boots in + // and the shape QA run #7637 measured. + orgContext: true, + security: new SecurityPlugin({ + defaultPermissionSets: [ + ...securityDefaultPermissionSets, + EditorSet as any, + ViewerSet as any, + ScopedSet as any, + ], + fallbackPermissionSet: 'member_default', + }), + }); + await stack.signIn(); // dev admin seed (first user) + aliceToken = await stack.signUp('owdw-alice@verify.test'); + bobToken = await stack.signUp('owdw-bob@verify.test'); + mallyToken = await stack.signUp('owdw-mallory@verify.test'); + carolToken = await stack.signUp('owdw-carol@verify.test'); + + ql = await stack.kernel.getServiceAsync('objectql'); + + const uid = async (email: string) => + (await ql.findOne('sys_user', { where: { email }, context: { ...SYS } }))?.id; + aliceId = await uid('owdw-alice@verify.test'); + bobId = await uid('owdw-bob@verify.test'); + const mallyId = await uid('owdw-mallory@verify.test'); + const carolId = await uid('owdw-carol@verify.test'); + + const bindSet = async (userId: string, name: string) => { + const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); + expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + await ql.insert( + 'sys_user_permission_set', + { user_id: userId, permission_set_id: setRow.id }, + { context: { ...SYS } }, + ); + }; + await bindSet(aliceId, 'owdw_editor'); + await bindSet(bobId, 'owdw_editor'); + await bindSet(mallyId, 'owdw_viewer'); + await bindSet(carolId, 'owdw_scoped'); + + // Rows are created over HTTP by ALICE so `created_by` is genuinely hers — + // a system-context seed would stamp no creator and the floor under test + // would never engage. + for (const object of [OPEN, READ, SECRET]) { + const mkRow = async (token: string, stage: string, title: string) => { + const res = await stack.apiAs(token, 'POST', `/data/${object}`, { + title, + stage, + body: 'seed', + owner_id: token === aliceToken ? aliceId : bobId, + }); + expect(res.status, `${object}: ${title} created`).toBeLessThan(300); + return idOf(await res.json()) as string; + }; + rows[object] = { + aliceOpen: await mkRow(aliceToken, 'open', 'alice open'), + aliceClosed: await mkRow(aliceToken, 'closed', 'alice closed'), + bobOwn: await mkRow(bobToken, 'open', 'bob own'), + }; + } + }, 180_000); + + afterAll(async () => { await stack?.stop(); }); + + // ── integrity: the fixture really is the measured shape ─────────────────── + // + // Every case below is worthless if Bob quietly created the rows, if the + // platform floor is outside its `positions` domain for these personas (it is + // gated to `org_member`), or if the three objects differ by more than the + // OWD. Assert all three BEFORE measuring. + + it('[integrity] alice created every probed row, bob holds the floor domain, the objects differ only by OWD', async () => { + for (const object of [OPEN, READ, SECRET]) { + const row = await ql.findOne(object, { where: { id: rows[object]!.aliceOpen }, context: { ...SYS } }); + expect(row?.created_by, `${object}: alice is the creator of the probed row`).toBe(aliceId); + const own = await ql.findOne(object, { where: { id: rows[object]!.bobOwn }, context: { ...SYS } }); + expect(own?.created_by, `${object}: bob created his own control row`).toBe(bobId); + } + + // The floor is positions-gated to `org_member`. If these personas did not + // hold that position, every refusal below would come from somewhere else + // and the file would be pinning a different mechanism than it claims. + const bobCtx = await authzFor(bobToken); + expect(bobCtx?.userId, 'the resolved principal is Bob').toBe(bobId); + expect(bobCtx?.positions, 'bob is inside the platform write floor’s positions domain') + .toContain('org_member'); + expect(bobCtx?.permissions, 'and holds the edit:true set').toContain('owdw_editor'); + + const schemas = [OPEN, READ, SECRET].map((o) => ql.getSchema(o)); + expect(schemas.map((s: any) => s.sharingModel)) + .toEqual(['public_read_write', 'public_read', 'private']); + for (const s of schemas) { + expect(Object.keys(s.fields ?? {}).sort(), 'identical field sets') + .toEqual(Object.keys(schemas[0]!.fields ?? {}).sort()); + } + }); + + // ── A. the headline (acceptance criterion 1) ────────────────────────────── + + it('[A public_read_write] an edit:true persona PATCHes a row it did NOT create → 2xx', async () => { + const res = await patchAs(bobToken, OPEN, rows[OPEN]!.aliceOpen, { body: 'contributor edit' }); + expect(res.status, `PATCH ${OPEN} (created by alice) as an edit:true persona`).toBeLessThan(300); + + const row = await ql.findOne(OPEN, { where: { id: rows[OPEN]!.aliceOpen }, context: { ...SYS } }); + expect(row?.body, 'the value actually persisted').toBe('contributor edit'); + }); + + it('[A control] the same persona still PATCHes its OWN row', async () => { + const res = await patchAs(bobToken, OPEN, rows[OPEN]!.bobOwn, { body: 'own edit' }); + expect(res.status).toBeLessThan(300); + }); + + // ── B. the object-level gate is untouched (acceptance criterion 2) ───────── + + it('[B object gate] an edit:false persona is refused with the OBJECT-level sentence, not the record one', async () => { + const res = await patchAs(mallyToken, OPEN, rows[OPEN]!.aliceOpen, { body: 'viewer edit' }); + expect(res.status, 'object-level refusal').toBe(403); + + const envelope = await envelopeOf(res); + expect(envelope?.code, 'ADR-0112 error code').toBe('PERMISSION_DENIED'); + const text = JSON.stringify(envelope); + expect(text, 'the object-level sentence').toContain(OBJECT_SENTENCE); + expect(text, 'and NOT the record-level one — the two must stay distinguishable') + .not.toContain(RECORD_SENTENCE); + }); + + // ── C. the floor still refuses where the OWD does not open writes (criterion 3) ── + + it('[C public_read] a non-creator write is STILL refused (403 PERMISSION_DENIED, record-level sentence)', async () => { + const res = await patchAs(bobToken, READ, rows[READ]!.aliceOpen, { body: 'should not land' }); + expect(res.status, `PATCH ${READ} across creators`).toBe(403); + const envelope = await envelopeOf(res); + expect(envelope?.code, 'ADR-0112 error code').toBe('PERMISSION_DENIED'); + expect(JSON.stringify(envelope), 'the RECORD-level sentence').toContain(RECORD_SENTENCE); + + const row = await ql.findOne(READ, { where: { id: rows[READ]!.aliceOpen }, context: { ...SYS } }); + expect(row?.body, 'nothing persisted').toBe('seed'); + }); + + it('[C private] a non-creator write is STILL refused', async () => { + const res = await patchAs(bobToken, SECRET, rows[SECRET]!.aliceOpen, { body: 'should not land' }); + expect(res.status, `PATCH ${SECRET} across creators`).toBeGreaterThanOrEqual(400); + const envelope = await envelopeOf(res); + expect(JSON.stringify(envelope)).toMatch(/PERMISSION_DENIED|RECORD_NOT_ACCESSIBLE|NOT_FOUND/); + + const row = await ql.findOne(SECRET, { where: { id: rows[SECRET]!.aliceOpen }, context: { ...SYS } }); + expect(row?.body, 'nothing persisted').toBe('seed'); + }); + + it('[C control] the owner-writes half of both OWDs still WORKS (a creator edits its own row)', async () => { + for (const object of [READ, SECRET]) { + const res = await patchAs(bobToken, object, rows[object]!.bobOwn, { body: 'own edit' }); + expect(res.status, `${object}: creator edits own row`).toBeLessThan(300); + } + }); + + // ── D. #7792 by-id write visibility is not regressed (criterion 4) ───────── + + it('[D #7792] a select-only-narrowed persona still cannot write a row OUTSIDE its select scope', async () => { + // In scope (`stage == 'open'`), created by alice — the fix's benefit reaches + // this persona too. + const inScope = await patchAs(carolToken, OPEN, rows[OPEN]!.aliceOpen, { body: 'carol in scope' }); + expect(inScope.status, 'a readable, non-created row is writable').toBeLessThan(300); + + // Out of scope (`stage == 'closed'`) — invisible on the read side, so the + // write must stay refused. This is what derive-from-select supplies once + // the floor is gone. + const readBack = await stack.apiAs(carolToken, 'GET', `/data/${OPEN}/${rows[OPEN]!.aliceClosed}`); + expect(readBack.status, 'the out-of-scope row is not even readable').toBeGreaterThanOrEqual(400); + + const outOfScope = await patchAs(carolToken, OPEN, rows[OPEN]!.aliceClosed, { body: 'must not land' }); + expect(outOfScope.status, 'an unreadable row must not be writable').toBeGreaterThanOrEqual(400); + expect(JSON.stringify(await envelopeOf(outOfScope))) + .toMatch(/PERMISSION_DENIED|RECORD_NOT_ACCESSIBLE|NOT_FOUND/); + + const row = await ql.findOne(OPEN, { where: { id: rows[OPEN]!.aliceClosed }, context: { ...SYS } }); + expect(row?.body, 'nothing persisted').toBe('seed'); + }); + + // ── F. the SECOND consumer of the same composition ──────────────────────── + // + // `POST /security/explain` reads the identical `computeLayeredRlsFilter`, so + // the card's other measurement — the rls layer reporting `narrows` for + // `update` on an object with ZERO authored RLS, while `read` reported + // `not_applicable` — has to move with the fix or the two consumers disagree. + // The `delete` probe is the control: same object, same principal, same + // request shape, and it must STILL report a narrowing because the delete + // floor is deliberately kept. + + it('[F explain] update stops reporting a phantom narrowing; delete on the same object still reports one', async () => { + const layerOf = async (operation: string, object: string) => { + const res = await stack.apiAs(bobToken, 'POST', '/security/explain', { object, operation, userId: bobId }); + expect(res.status, `explain ${operation} ${object}`).toBe(200); + const body: any = await res.json(); + return (body.layers ?? []).find((l: any) => l.layer === 'rls'); + }; + + expect((await layerOf('read', OPEN))?.verdict, 'read was always correct').toBe('not_applicable'); + expect( + (await layerOf('update', OPEN))?.verdict, + 'update must now agree with read — no RLS is authored on this object', + ).toBe('not_applicable'); + expect( + (await layerOf('delete', OPEN))?.verdict, + 'the delete floor is KEPT, so delete still narrows — the control that proves the fix is scoped', + ).toBe('narrows'); + expect( + (await layerOf('update', READ))?.verdict, + 'and an OWD that does not open writes still narrows updates', + ).toBe('narrows'); + }); + + // ── E. the boundary: public_read_write is read+EDIT, not delete ─────────── + + it('[E delete floor] `public_read_write` does not open DELETE — a non-creator delete is still refused', async () => { + const res = await stack.apiAs(bobToken, 'DELETE', `/data/${OPEN}/${rows[OPEN]!.aliceClosed}`); + expect(res.status, 'delete is outside what public_read_write declares').toBeGreaterThanOrEqual(400); + expect(JSON.stringify(await envelopeOf(res))) + .toMatch(/PERMISSION_DENIED|RECORD_NOT_ACCESSIBLE|NOT_FOUND/); + + const row = await ql.findOne(OPEN, { where: { id: rows[OPEN]!.aliceClosed }, context: { ...SYS } }); + expect(row, 'the row survives').toBeTruthy(); + }); +});