From c6a48bd1d150c0a676974e44ea5b701ea994ab13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:40:16 +0000 Subject: [PATCH 1/3] fix(plugin-security): a controlled_by_parent middle master is judged at the chain hop by its own direct-write composition Maintainer ruling A on #23093 extends the 2026-08-15 ruling to the chain walk's middle hop: in assertMasterRowEditable, a master that is itself controlled_by_parent, on a non-delegated write, passes masterGateCoversThisWrite, so only the platform ownership floor yields there. An app-authored update policy and the derive-from-select floor still run, and a delegated write keeps both floors. A line write under a quote is now admitted exactly when the same principal's direct update of the quote is. Pins: controlled-by-parent-chain-middle-master.test.ts (real ObjectQL + SQL driver, real sharing middleware, member_default floor armed). Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .changeset/23093-cbp-middle-master-floor.md | 18 + ...lled-by-parent-chain-middle-master.test.ts | 424 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 34 +- 3 files changed, 469 insertions(+), 7 deletions(-) create mode 100644 .changeset/23093-cbp-middle-master-floor.md create mode 100644 packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts diff --git a/.changeset/23093-cbp-middle-master-floor.md b/.changeset/23093-cbp-middle-master-floor.md new file mode 100644 index 00000000000..3c03cc26d41 --- /dev/null +++ b/.changeset/23093-cbp-middle-master-floor.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-security': patch +--- + +A `controlled_by_parent` master that is itself a detail decides who may write its details the way its own record update is decided: whoever can edit a quote can edit its lines + +Clause-②: no + +In a two-level master-detail chain (a quote line under a quote under an account, every level below the account `controlled_by_parent`), one person got two answers about the same quote. An organization member who could edit a quote they had not created was allowed to update the quote itself, but every insert, update and delete of its lines was refused `403 PERMISSION_DENIED` ("requires edit access to its master record … not editable by this user (row-level security)"). Only the quote's creator could change its lines. The quote's own update hands the platform's creator-only floor (`owner_only_writes`, positions `['org_member']`) to the master-detail write check, by the 2026-08-15 ruling; the check that a line write runs on the quote kept that floor. + +Now the line write asks the same question the quote's own update asks. By the maintainer's ruling (letter A), a line write under a middle master is admitted exactly when the same person may update that middle master directly: + +- **Admitted now:** an organization member who can update the quote (and the account above it) may insert, update and delete its lines, whoever created the quote. +- **Still refused:** the account above is not editable or not readable; the quote is not editable through its object permission; or an app-authored update rule on the quote refuses the person. +- **Also refused now:** the quote's creator, writing the lines of a quote that an app-authored update rule refuses them. The quote's own update already refused them. The line write had admitted them, because the floor and the app's rule each admitted a row on its own. +- **Unchanged:** a write made on behalf of another user keeps both principals' floors at every level. A master that governs its own rows, a single-level detail, the cycle guard and the depth bound are untouched. + +`security/explain` and the parent gates of `sys_attachment` and `sys_comment` ask the same check, so they give the same answers. diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts new file mode 100644 index 00000000000..d9fc5917528 --- /dev/null +++ b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts @@ -0,0 +1,424 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0055 amendment, #23093] A `controlled_by_parent` MIDDLE master is judged, +// at the chain walk's hop, by the composition its own direct by-id write is +// judged by — with the platform ownership floor ARMED. +// +// ## What was measured before this suite existed +// +// A two-level chain, the shape of HotCRM's quote lines: +// +// cbc_line --quote--> cbc_quote --account--> cbc_account +// controlled_by_parent controlled_by_parent private +// +// For an organization member (position `org_member`, so the platform floor +// `owner_only_writes`, `created_by == current_user.id`, binds), one principal and +// one quote row got two answers to one `update`: +// +// • the quote's own by-id write — step 2.7 hands a `controlled_by_parent` +// object's floor to the master gate (commit 6feac910b, the 2026-08-15 +// ruling), so it was ADMITTED for a member who may edit the account; +// • a write to any line under it — the chain walk's hop asked the quote's +// write RLS with that floor still standing, so it was REFUSED `403 … +// (row-level security)` unless the member had created the quote. +// +// The maintainer's ruling on #23093 (letter A) extends the 2026-08-15 ruling to +// that hop. So the invariant this file pins, case by case, is the ruling's own: +// for one principal, a line write under a middle master is admitted IFF that +// principal's direct by-id update of the middle master is admitted. +// +// ## Why this is not a case in `controlled-by-parent-chain.test.ts` +// +// That suite's rig is a read-only engine double whose principals hold no +// position, so the floor never binds there and its two-level writes were +// admitted before and after this change. The floor needs `org_member`, a real +// composition and real rows; the ruling's evidence is "admitted AND the row +// changed", which a double without a write verb cannot show. +// +// ## The four ruled pins, and the one control the analysis left unmeasured +// +// 1. middle editable → the line write is ADMITTED, and the row changed; +// 2. root not editable → REFUSED (the walk reaches the root and refuses there); +// 3. middle not editable → REFUSED — by its object grant, and by an +// APP-AUTHORED update policy on the middle master (only the PLATFORM floor +// yields; the app's own policy still decides, at the hop as on the door); +// 4. a DELEGATED write keeps both floors — the agent's and the delegator's. +// +// Every refusal pin was ablated against the implementation (the PR body and the +// card's dev report carry the red runs); no ablation scaffolding lives here. +// +// ## The rig +// +// The real `SecurityPlugin` over a real `ObjectQL` and SQL driver +// (better-sqlite3, in memory), the real `SharingService` as the kernel's +// `sharing` service with its middleware on the engine after this plugin's, and +// the platform's own `member_default` set carrying the floor. The identity +// objects `resolveUserAuthzGrants` reads are registered so a delegator resolves +// from stored rows, as it does on a served stack. + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; + +const SYS = { context: { isSystem: true } } as never; +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +const ACCOUNT = 'cbc_account'; +const QUOTE = 'cbc_quote'; +const LINE = 'cbc_line'; + +/** Owns and creates every row nobody else is named for. Never a caller. */ +const REP = 'usr_rep'; +const MANAGER = 'usr_manager'; +const ROOT_READER = 'usr_root_reader'; +const MIDDLE_READER = 'usr_middle_reader'; +const LOCKED = 'usr_locked'; +const PLAIN = 'usr_plain'; +/** Delegation case (i): the agent is the floor-bound member, the delegator is not. */ +const DELEGATOR_UNBOUND = 'usr_delegator_unbound'; +/** Delegation case (ii): the agent is not floor-bound, the delegator is. */ +const AGENT_UNBOUND = 'usr_agent_unbound'; +const DELEGATOR_MEMBER = 'usr_delegator_member'; +const ORG = 'org_cbc'; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; +/** The root's editor: Modify All Data on the account, which the sharing verdict answers `allow` for. */ +const ROOT_EDIT = { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true }; + +/** Edits the root through Modify All Data, plain CRUD on the quote and its lines. The card's persona. */ +const MANAGER_SET = PermissionSetSchema.parse({ + name: 'cbc_manager', + objects: { [ACCOUNT]: ROOT_EDIT, [QUOTE]: CRUD, [LINE]: CRUD }, +}); + +/** Reads every account, edits none — the root is readable and NOT editable. */ +const ROOT_READER_SET = PermissionSetSchema.parse({ + name: 'cbc_root_reader', + objects: { [ACCOUNT]: { allowRead: true, viewAllRecords: true }, [QUOTE]: CRUD, [LINE]: CRUD }, +}); + +/** Edits the root, holds NO update grant on the middle master. */ +const MIDDLE_READER_SET = PermissionSetSchema.parse({ + name: 'cbc_middle_reader', + objects: { [ACCOUNT]: ROOT_EDIT, [QUOTE]: { allowRead: true }, [LINE]: CRUD }, +}); + +/** + * An APP-AUTHORED update policy on the middle master, carried by a set that + * grants nothing else: only an `open` quote may be updated. Nothing the platform + * ships spells it, so it is the provenance control — the hop drops the PLATFORM + * floor and must leave this policy deciding, exactly as the quote's own door does. + */ +const QUOTE_OPEN_ONLY_SET = PermissionSetSchema.parse({ + name: 'cbc_quote_open_only', + objects: {}, + rowLevelSecurity: [{ name: 'cbc_quote_updates_open_only', object: QUOTE, operation: 'update', using: "stage == 'open'" }], +}); + +/** CRUD on the account WITHOUT any bypass: a member reaches only the accounts it owns. */ +const PLAIN_SET = PermissionSetSchema.parse({ + name: 'cbc_plain', + objects: { [ACCOUNT]: { allowRead: true, allowCreate: true, allowEdit: true }, [QUOTE]: CRUD, [LINE]: CRUD }, +}); + +const SETS = [MEMBER_DEFAULT, MANAGER_SET, ROOT_READER_SET, MIDDLE_READER_SET, QUOTE_OPEN_ONLY_SET, PLAIN_SET]; + +/** The execution context `resolveAuthzContext` hands the middleware for an organization member holding the sets. */ +const memberOf = (userId: string, ...sets: string[]) => ({ + userId, + positions: ['org_member'], + permissions: [...sets, 'member_default'], + posture: 'MEMBER', +}); +const MANAGER_CALLER = memberOf(MANAGER, MANAGER_SET.name); +const ROOT_READER_CALLER = memberOf(ROOT_READER, ROOT_READER_SET.name); +const MIDDLE_READER_CALLER = memberOf(MIDDLE_READER, MIDDLE_READER_SET.name); +const LOCKED_CALLER = memberOf(LOCKED, MANAGER_SET.name, QUOTE_OPEN_ONLY_SET.name); +const PLAIN_CALLER = memberOf(PLAIN, PLAIN_SET.name); +/** Holds the manager's grants and NO `org_member`, so no floor binds it. */ +const AGENT_UNBOUND_CALLER = { userId: AGENT_UNBOUND, positions: [], permissions: [MANAGER_SET.name], posture: 'MEMBER' }; +const onBehalfOf = (caller: object, delegator: string) => ({ ...caller, onBehalfOf: { userId: delegator } }); + +type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number; message: string }; + +const landed = (p: Promise): Promise => + p.then( + () => ({ kind: 'admitted' as const }), + (e: any) => ({ + kind: 'refused' as const, + code: e?.code == null ? undefined : String(e.code), + status: e?.statusCode ?? e?.status, + message: `${String(e?.message ?? '')} ${String(e?.developerMessage ?? '')}`, + }), + ); + +let engine: ObjectQL; +let seq = 0; + +beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.controlled-by-parent-chain-middle-master', + name: 'A controlled_by_parent middle master under the platform ownership floor', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysRecordShare, + SysUser, + SysOrganization, + SysMember, + SysUserPosition, + SysUserPermissionSet, + { + name: ACCOUNT, + label: 'Account', + sharingModel: 'private', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + owner_id: { name: 'owner_id', type: 'text' }, + created_by: { name: 'created_by', type: 'text' }, + }, + }, + { + name: QUOTE, + label: 'Quote', + sharingModel: 'controlled_by_parent', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + stage: { name: 'stage', type: 'text' }, + account: { name: 'account', type: 'master_detail', reference: ACCOUNT, required: true }, + created_by: { name: 'created_by', type: 'text' }, + }, + }, + { + name: LINE, + label: 'Quote line', + sharingModel: 'controlled_by_parent', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + quote: { name: 'quote', type: 'master_detail', reference: QUOTE, required: true }, + created_by: { name: 'created_by', type: 'text' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + // The two delegators exist as users, so the on-behalf-of link resolves rather + // than failing closed. Each holds the manager's set through a stored position + // row; only DELEGATOR_MEMBER is an organization member, so only it carries the floor. + await engine.insert('sys_user', [ + { id: DELEGATOR_UNBOUND, name: 'Delegator, not a member', email: 'delegator-unbound@cbc.test' }, + { id: DELEGATOR_MEMBER, name: 'Delegator, a member', email: 'delegator-member@cbc.test' }, + ], SYS); + await engine.insert('sys_organization', [{ id: ORG, name: 'CBC', slug: 'cbc' }], SYS); + await engine.insert('sys_member', [{ id: 'mem_delegator', user_id: DELEGATOR_MEMBER, organization_id: ORG, role: 'member' }], SYS); + await engine.insert('sys_user_position', [ + { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name }, + { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name }, + ], SYS); + + let security: unknown; + let sharing: SharingService | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? SETS.find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null, + list: async () => SETS, + }, + get sharing() { return sharing; }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { if (name === 'security') security = svc; }, + // The lifecycle hooks are collected and never fired, so no bootstrap read + // is left running into the engine teardown. + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + sharing = new SharingService({ engine: engine as never, securityService: () => security as never }); + engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' }); +}); + +afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } +}); + +interface Chain { account: string; quote: string; line: string } + +/** + * A fresh chain per case, so a delete that lands never moves another case's + * row. Every row is created by `REP` unless the case names its creator — the + * floor's question is exactly "who created this row". + */ +async function seedChain(opts: { accountOwner?: string; accountCreatedBy?: string; quoteCreatedBy?: string; stage?: string; lineCreatedBy?: string } = {}): Promise { + const n = ++seq; + const chain = { account: `acct_${n}`, quote: `quote_${n}`, line: `line_${n}` }; + await engine.insert(ACCOUNT, [{ id: chain.account, name: `account ${n}`, owner_id: opts.accountOwner ?? REP, created_by: opts.accountCreatedBy ?? REP }], SYS); + await engine.insert(QUOTE, [{ id: chain.quote, name: `quote ${n}`, stage: opts.stage ?? 'open', account: chain.account, created_by: opts.quoteCreatedBy ?? REP }], SYS); + await engine.insert(LINE, [{ id: chain.line, name: 'as seeded', quote: chain.quote, created_by: opts.lineCreatedBy ?? REP }], SYS); + return chain; +} + +const stored = (object: string, id: string) => + engine.findOne(object, { where: { id }, context: { isSystem: true } } as never) as Promise | null>; +const linesUnder = async (quote: string) => + ((await engine.find(LINE, { where: { quote }, context: { isSystem: true } } as never)) as Array>).length; + +/** The middle master's own door: a by-id update of the quote, as this principal. */ +const updateQuote = (chain: Chain, caller: object) => + landed(engine.update(QUOTE, { name: 'quote edited' }, { where: { id: chain.quote }, context: { ...caller } } as never)); +const updateLine = (chain: Chain, caller: object) => + landed(engine.update(LINE, { name: 'line edited' }, { where: { id: chain.line }, context: { ...caller } } as never)); +const insertLine = (chain: Chain, caller: object) => + landed(engine.insert(LINE, { name: 'line inserted', quote: chain.quote }, { context: { ...caller } } as never)); +const deleteLine = (chain: Chain, caller: object) => + landed(engine.delete(LINE, { where: { id: chain.line }, context: { ...caller } } as never)); + +/** + * The ADR-0112 envelope of a master-gate refusal, and its first sentence: it + * names the CALLER's own object and verb (#7474), never an ancestor. + */ +function expectMasterGateRefusal(answer: Answer, verb: string, refusingMaster: string): void { + expect(answer).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + const message = (answer as { message: string }).message; + expect(message).toContain(`[Security] Access denied: ${verb} on '${LINE}' requires edit access to its master record`); + expect(message).toContain(`master '${refusingMaster}'`); +} + +/** All three line verbs refused on the master gate, and no row moved. */ +async function expectLineWritesRefused(chain: Chain, caller: object, refusingMaster: string): Promise { + const before = await linesUnder(chain.quote); + expectMasterGateRefusal(await updateLine(chain, caller), 'update', refusingMaster); + expectMasterGateRefusal(await insertLine(chain, caller), 'insert', refusingMaster); + expectMasterGateRefusal(await deleteLine(chain, caller), 'delete', refusingMaster); + expect((await stored(LINE, chain.line))?.name, 'the refused update left the row as seeded').toBe('as seeded'); + expect(await linesUnder(chain.quote), 'the refused insert added no row and the refused delete removed none').toBe(before); +} + +describe('[#23093] pin 1 — the middle master is editable: its lines are writable, as its own door is', () => { + it('a member who did not create the quote: the quote PATCH lands, and so do the line update, insert and delete', async () => { + const chain = await seedChain(); + expect(await updateQuote(chain, MANAGER_CALLER), 'the middle master\'s own door').toEqual({ kind: 'admitted' }); + expect((await stored(QUOTE, chain.quote))?.name).toBe('quote edited'); + + expect(await updateLine(chain, MANAGER_CALLER), 'line update').toEqual({ kind: 'admitted' }); + expect((await stored(LINE, chain.line))?.name, 'the line row changed').toBe('line edited'); + + const before = await linesUnder(chain.quote); + expect(await insertLine(chain, MANAGER_CALLER), 'line insert').toEqual({ kind: 'admitted' }); + expect(await linesUnder(chain.quote), 'the inserted line landed under the quote').toBe(before + 1); + + expect(await deleteLine(chain, MANAGER_CALLER), 'line delete').toEqual({ kind: 'admitted' }); + expect(await stored(LINE, chain.line), 'the deleted line is gone').toBeNull(); + }); +}); + +describe('[#23093] pin 2 — the root is not editable: the walk reaches the root and refuses there', () => { + it('the root readable but not editable: the quote PATCH is refused, and every line write is refused naming the root', async () => { + const chain = await seedChain(); + const direct = await updateQuote(chain, ROOT_READER_CALLER); + expect(direct, 'the middle master\'s own door').toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + await expectLineWritesRefused(chain, ROOT_READER_CALLER, ACCOUNT); + }); + + it('the root not even readable (owned by another, never shared): the quote PATCH is refused, and so is a line insert', async () => { + // A by-id line write would stop earlier — the line is hidden by the read + // derivation, so its door answers not-found first — so the master gate is + // measured on the insert, which has no pre-image. + const chain = await seedChain(); + expect(await updateQuote(chain, PLAIN_CALLER), 'the middle master\'s own door').toMatchObject({ kind: 'refused' }); + const before = await linesUnder(chain.quote); + expectMasterGateRefusal(await insertLine(chain, PLAIN_CALLER), 'insert', ACCOUNT); + expect(await updateLine(chain, PLAIN_CALLER)).toMatchObject({ kind: 'refused' }); + expect(await deleteLine(chain, PLAIN_CALLER)).toMatchObject({ kind: 'refused' }); + expect((await stored(LINE, chain.line))?.name).toBe('as seeded'); + expect(await linesUnder(chain.quote)).toBe(before); + }); +}); + +describe('[#23093] pin 3 — the middle master is not editable: its lines are not writable', () => { + it('no update grant on the middle master: the quote PATCH is refused, and every line write is refused naming the quote', async () => { + const chain = await seedChain(); + expect(await updateQuote(chain, MIDDLE_READER_CALLER), 'the middle master\'s own door').toMatchObject({ kind: 'refused', status: 403 }); + await expectLineWritesRefused(chain, MIDDLE_READER_CALLER, QUOTE); + }); + + it('an APP-AUTHORED update policy refuses the quote: the hop refuses its lines too — only the platform floor yields', async () => { + const chain = await seedChain({ stage: 'closed' }); + const direct = await updateQuote(chain, LOCKED_CALLER); + expect(direct, 'the middle master\'s own door').toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect((direct as { message: string }).message).toContain('(row-level security)'); + await expectLineWritesRefused(chain, LOCKED_CALLER, QUOTE); + }); + + it('…and it still refuses when the caller CREATED that quote: the app policy decides, the creator floor does not widen it', async () => { + // Before #23093 the hop composed the floor and the authored policy as + // alternatives, so the creator's line writes were admitted under a quote + // its own door refuses them — the same two-answer shape, the other way. + const chain = await seedChain({ stage: 'closed', quoteCreatedBy: LOCKED, lineCreatedBy: LOCKED }); + expect(await updateQuote(chain, LOCKED_CALLER), 'the middle master\'s own door').toMatchObject({ kind: 'refused', status: 403 }); + await expectLineWritesRefused(chain, LOCKED_CALLER, QUOTE); + }); + + it('CONTROL: the same policy admits an open quote, and then its lines are writable — the policy, not the set, is what refused', async () => { + const chain = await seedChain({ stage: 'open' }); + expect(await updateQuote(chain, LOCKED_CALLER)).toEqual({ kind: 'admitted' }); + expect(await updateLine(chain, LOCKED_CALLER)).toEqual({ kind: 'admitted' }); + expect((await stored(LINE, chain.line))?.name).toBe('line edited'); + }); +}); + +describe('[#23093] pin 4 — a DELEGATED write keeps both floors at the middle master', () => { + it('the agent\'s floor: a member acting for an unbound delegator is refused the line write, as its delegated quote PATCH is', async () => { + // The agent created the root and the line, so every floor but the middle + // master's passes for it; the delegator is bound by no floor at all. Only + // the agent's floor on the quote (created by REP) can refuse. + const chain = await seedChain({ accountCreatedBy: MANAGER, lineCreatedBy: MANAGER }); + const delegated = onBehalfOf(MANAGER_CALLER, DELEGATOR_UNBOUND); + expect(await updateQuote(chain, delegated), 'the delegated quote PATCH').toMatchObject({ kind: 'refused', status: 403 }); + expectMasterGateRefusal(await updateLine(chain, delegated), 'update', QUOTE); + expectMasterGateRefusal(await insertLine(chain, delegated), 'insert', QUOTE); + expect((await stored(LINE, chain.line))?.name).toBe('as seeded'); + // Control — delegation is the only variable: the same member, acting for itself. + expect(await updateLine(chain, MANAGER_CALLER)).toEqual({ kind: 'admitted' }); + }); + + it('the delegator\'s floor: an unbound agent acting for a member delegator is refused the line write, as its delegated quote PATCH is', async () => { + const chain = await seedChain({ accountCreatedBy: DELEGATOR_MEMBER, lineCreatedBy: DELEGATOR_MEMBER }); + const delegated = onBehalfOf(AGENT_UNBOUND_CALLER, DELEGATOR_MEMBER); + expect(await updateQuote(chain, delegated), 'the delegated quote PATCH').toMatchObject({ kind: 'refused', status: 403 }); + expectMasterGateRefusal(await updateLine(chain, delegated), 'update', QUOTE); + expectMasterGateRefusal(await insertLine(chain, delegated), 'insert', QUOTE); + expect((await stored(LINE, chain.line))?.name).toBe('as seeded'); + // Control — delegation is the only variable: the same agent, acting for itself. + expect(await updateLine(chain, AGENT_UNBOUND_CALLER)).toEqual({ kind: 'admitted' }); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0986060dcc5..12a9565cd3b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -9477,15 +9477,34 @@ export class SecurityPlugin implements Plugin { // • the verb is `update`, matching the object-level check above and leg 2 // below: this gate's question is EDIT access to the master, never the // detail's own verb; - // • `masterGateCoversThisWrite` is deliberately NOT set. That knob is - // commit 6feac910b's, and it hands a `controlled_by_parent` object's floor to THIS - // gate; setting it here would hand a nested master's floor to a gate that - // is already running, on a path no measurement covers. A master that is - // itself a detail keeps its floor exactly as it does today. + // • [#23093] `masterGateCoversThisWrite` IS set for a master that is itself + // `controlled_by_parent`, on a non-delegated write. Maintainer ruling on + // #23093 (letter A, 2026-10-12) extends the 2026-08-15 ruling — commit + // 6feac910b, which hands such an object's platform floor to its master + // gate on its own by-id write (step 2.7) — to this hop. Before it, one + // principal and one middle master row got two answers to one `update`: + // the master's own door dropped the floor, a write to any of its details + // kept it here. So an organization member who could edit a quote could + // not edit its lines unless they had created the quote, and the quote's + // creator could edit the lines of a quote an app policy refused them + // (the floor and that policy compose as alternatives). The hop now asks + // the composition that door asks, clause for clause: only the PLATFORM + // floor yields; an app-authored update policy on the master and the + // #7665 derive-from-select floor still run, and refuse here exactly as + // they refuse the door. The vouch holds structurally, as step 2.7's + // does: the flag is set on the very predicate + // (`declaresControlledByParent(rel.master)`) that makes the walk in + // {@link SecurityPlugin.assertControlledByParentWrite} continue to this + // master's own master, so this master's own master gate runs next, for + // this principal, on this pass — or the walk refuses (cycle, depth bound, + // broken chain). A master that governs its own rows never sets it. + // Pinned in `controlled-by-parent-chain-middle-master.test.ts`. // // [ADR-0090 D10] The on-behalf-of path is EXCLUDED, mirroring step 2.7's - // `floorApplies`. The delegation fact is read off `opCtx.context` rather - // than off `context`, because THIS METHOD RUNS TWICE for a delegated write + // `floorApplies` and its `masterGateCoversThisWrite = !delegatorSets` — from + // the sharing-verdict floor drop and from the vouch alike. The delegation + // fact is read off `opCtx.context` rather than off `context`, because THIS + // METHOD RUNS TWICE for a delegated write // — once per principal, with the delegator's own context substituted — and // only the shared `opCtx` carries the link on both passes. (At this point // `!!opCtx.context?.onBehalfOf?.userId` is the middleware's `!!delegatorSets`: @@ -9515,6 +9534,7 @@ export class SecurityPlugin implements Plugin { : false; const masterWriteFilter = await this.computeRlsFilter(permissionSets, rel.master, 'update', context, { dropPlatformOwnershipFloor, + masterGateCoversThisWrite: !delegatedWrite && this.declaresControlledByParent(rel.master), }); if (masterWriteFilter) { let visible: unknown = null; From 343e21e33979846edb877f8f682fb5864b5a8b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:08:35 +0000 Subject: [PATCH 2/3] test(verify): the two-level parent write reach is proven once the chain hop agrees with the quote's own write PR #23091 pinned today's answer for pwr_quote_line as contradicted. With the chain hop judged by the middle master's own direct-write composition (ruling A on #23093), every persona's quote-line writes agree with its update of the quote: the object is proven and the run has no hard failure. Expectation flips only. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .../verify/src/parent-write-reach.test.ts | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts index fbe87f73169..a417397ffad 100644 --- a/packages/verify/src/parent-write-reach.test.ts +++ b/packages/verify/src/parent-write-reach.test.ts @@ -240,34 +240,32 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite expect(shape(report, 'pwr_quote', 'position:pwr_rep', 'own')).toEqual(NO_DELETE); }); - it("finds today's engine refusing a two-level detail whose master the persona edits", () => { + it('proves a two-level detail whose master the persona edits, for every persona, as the quote\'s own write answers', () => { // ADR-0055's amendment: the write gate walks the chain hop by hop, and a - // detail whose whole chain is reachable stays writable. Measured on this - // engine, a quote line is refused for every persona, its own chain - // included, while the same persona's empty update of the quote is admitted. - // This flips to `proven` the day the chain walk agrees with the quote's own write. + // detail whose whole chain is reachable stays writable. Since the + // maintainer's ruling A on #23093 the hop judges the quote by the + // composition its own write is judged by, so a quote line agrees with the + // same persona's update of the quote, its own chain included. const r = resultFor(report, 'pwr_quote_line'); - expect(r.status).toBe('contradicted'); + expect(r.status, formatParentWriteReachReport(report)).toBe('proven'); for (const masters of ['other', 'own'] as const) { const reach = reachOf(report, 'pwr_quote_line', 'master-all-records', masters); expect(reach.masterEdit).toBe('editable'); - expect(reach.probes.map((p) => p.outcome)).toEqual(['contradicted', 'contradicted', 'contradicted', 'contradicted', 'contradicted']); - for (const p of reach.probes) { - expect(p.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_quote_line' }); - expect(p.detail).toMatch(/^the persona holds allow(Edit|Delete|Create) on pwr_quote_line and edited its master pwr_quote, and the (edit|delete|create) was refused/); - } + // Admitted, and the record shows each write landed (the family's own read-back). + expect(shape(report, 'pwr_quote_line', 'master-all-records', masters)).toEqual(ALL_ALLOWED); + for (const p of reach.probes) expect(p.answer).toMatchObject({ kind: 'written' }); // Where the conjunction refuses, the refusal still holds. expect(shape(report, 'pwr_quote_line', 'detail-only', masters)).toEqual(ALL_REFUSED); } - expect(formatParentWriteReachReport(report)).toContain('✗ pwr_quote_line (pwr_quote_line.quote → pwr_quote, pwr_quote.account → pwr_account) CONTRADICTED'); + expect(formatParentWriteReachReport(report)).toContain('✓ pwr_quote_line (pwr_quote_line.quote → pwr_quote, pwr_quote.account → pwr_account) held on'); }); - it('counts every reach and write, and the contradicted chain is its one hard failure', () => { + it('counts every reach and write, and every object is proven', () => { expect(report.summary).toEqual({ objects: 3, probed: 3, - proven: 2, - contradicted: 1, + proven: 3, + contradicted: 0, failed: 0, inconclusive: 0, excluded: 0, @@ -276,12 +274,12 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite // 3 objects × 5 personas × 2 master chains, the rep's three chains another principal owns outside its reach. reaches: { probed: 27, unreadable: 3, unmeasured: 0 }, probes: { edit: 54, delete: 54, create: 27 }, - held: 104, - contradictions: 31, + held: 135, + contradictions: 0, unattributed: 0, faults: 0, notRun: 0, - hardFailures: 1, + hardFailures: 0, }); }); }); From 15058d6ff808951416948a1d83dcb99f120f8a02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:42:32 +0000 Subject: [PATCH 3/3] docs(verify, plugin-security): the two-level controlled_by_parent write is described as ruled, and the coverage vouch lists the chain hop Four texts this PR's change made false or incomplete, prose and comments only: the pending parent-write-reach release note, the verify README and the cli.mdx family description now state that a detail under a controlled_by_parent middle master is writable exactly when the caller may update that middle master directly (which needs edit access to the root), and that the family reports such a chain proven; the masterGateCoversThisWrite option docblock lists the chain hop among the call sites that vouch, pointing at that call site's own bullet. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .changeset/15953-verify-parent-write-reach-family.md | 2 +- content/docs/deployment/cli.mdx | 9 +++++---- packages/plugins/plugin-security/src/security-plugin.ts | 5 +++++ packages/verify/README.md | 9 +++++---- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.changeset/15953-verify-parent-write-reach-family.md b/.changeset/15953-verify-parent-write-reach-family.md index 31b67a49e79..c0ca2a2f98f 100644 --- a/.changeset/15953-verify-parent-write-reach-family.md +++ b/.changeset/15953-verify-parent-write-reach-family.md @@ -16,7 +16,7 @@ The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creati The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. -Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and the run fails. +A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`. **`@objectstack/verify` — new exports.** diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f83c26a079d..7ec06a1ba69 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -2011,10 +2011,11 @@ os verify --parent-write-reach # Also prove each controlled_by_p naming the detail and the operation (a rule, a hook's own code, a refusal on another object) is reported inconclusive, never proven. An object with two `master_detail` fields, none, or a chain at the write gate's depth - bound is reported `excluded`. Today's engine refuses every write to a detail - two levels below its root master (a line under a quote under an account), - even where the caller edits the middle master, so an app that declares such - a chain fails the run. + bound is reported `excluded`. A detail two levels below its root master (a + line under a quote under an account) is answered the same way: its write is + admitted exactly when the caller holds the verb on the detail and may update + the middle master directly, which also needs edit access to the root, and + refused otherwise, so such a chain is reported proven. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or `group`) asks for the same boot. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 12a9565cd3b..0c623322762 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -458,6 +458,11 @@ interface RlsFilterOptions { * sits directly below it on the same middleware pass, guarded by a * SUPERSET of step 2.7's own condition (same `permissionSets`/`userId`/ * `ql` triple, same single target id, one extra operation — `insert`); + * • [#23093] the chain walk's master-edit leg + * ({@link SecurityPlugin.assertMasterRowEditable}) sets it on a hop whose + * master is itself `controlled_by_parent`, on a non-delegated write. Why + * the walk covers that master's write is stated once, at that call site's + * `masterGateCoversThisWrite` bullet — not restated here; * • the BULK write path does NOT, and must not: with no single id, * `assertControlledByParentWrite` returns early and nothing replaces the * floor. Reading the posture alone here would have widened bulk writes on diff --git a/packages/verify/README.md b/packages/verify/README.md index 5d8705cf861..1890f66f530 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -133,10 +133,11 @@ names, while the REST envelope names the route's object whatever was refused. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. The system door bypasses -authorization and is not asked. Today's engine refuses every write to a detail -two levels below its root master, even where the caller edits the middle -master, so on an app that declares such a chain the family reports a -contradiction and fails the run. +authorization and is not asked. A detail two levels below its root master is +answered the same way: its write is admitted exactly when the caller holds the +verb on the detail and may update the middle master directly, which also needs +edit access to the root, and refused otherwise, so the family reports such a +chain `proven`. Exit code is **non-zero** on real failures (`create-failed`, `read-failed`, `fidelity-gaps`, `rls-hole`, with `--reference-shapes` a field with `holes` or