From 38081b6a05b3c5263f878d01b19477ec64defbc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:52:21 +0000 Subject: [PATCH 1/3] fix(plugin-security): the controlled_by_parent master-edit leg asks the master's own write-door question The object-level leg of the controlled_by_parent detail write gate asked only for an object-level update grant on the master. It now asks what the master's own write door asks of an update on the master, with that door's inputs: the posture resolves, the capabilities the master requires for update are held, and the grant is asked under the master's posture. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../24986-cbp-master-edit-object-question.md | 13 + ...lled-by-parent-master-object-write.test.ts | 380 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 30 +- 3 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 .changeset/24986-cbp-master-edit-object-question.md create mode 100644 packages/plugins/plugin-security/src/controlled-by-parent-master-object-write.test.ts diff --git a/.changeset/24986-cbp-master-edit-object-question.md b/.changeset/24986-cbp-master-edit-object-question.md new file mode 100644 index 00000000000..af0b93a1ddd --- /dev/null +++ b/.changeset/24986-cbp-master-edit-object-question.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a `controlled_by_parent` detail write asks its master the object-level question the master's own write door asks (ADR-0055 §3) + +Clause-②: no + +ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master. The master-edit gate's object-level leg asked only for an object-level `update` grant on the master. It now asks what the master's own write door asks of an `update` on the master, with that door's inputs: the master's security posture must resolve, the capabilities the master requires for `update` (ADR-0066 D3) must be held, and the grant is asked under the master's posture (ADR-0066 D2). A principal the master's own write door refuses at its object gate can no longer write a detail under that master. + +- **Every door that asks the leg.** An insert, a by-id update and a by-id delete of a detail answer `PERMISSION_DENIED` (403) and write nothing. `checkControlledByParentWrite`, which the comment and attachment parent gates ask, answers `deny` on the `object_permission` leg. A record-grained `security/explain` of a by-id write follows the same leg. +- **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. +- **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before. Predicate (bulk) writes are not this leg's: they stay scoped by the detail's read scope. diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-master-object-write.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-master-object-write.test.ts new file mode 100644 index 00000000000..274b723de70 --- /dev/null +++ b/packages/plugins/plugin-security/src/controlled-by-parent-master-object-write.test.ts @@ -0,0 +1,380 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0055 §3] Writing a `controlled_by_parent` detail requires EDIT access to + * its master, and the master's OBJECT-level edit admission is the one its own + * write door asks. + * + * §3: "editing/deleting/creating a detail requires edit access to its master". + * The master-edit gate's object-level leg is therefore the master write door's + * object gate, asked of an `update` on the master with that door's own inputs: + * + * - the master's posture resolves, and an unresolvable one refuses; + * - the capabilities the master requires for `update` are held; + * - the CRUD grant is asked under the master's posture. + * + * Pinned at every door that asks the leg: an insert, a by-id update and a + * by-id delete through the engine, and the security service's master-detail + * write check (`checkControlledByParentWrite`) that the parent gates of other + * subsystems ask. Each refusal is the leg's own, `object_permission`, and + * writes nothing. + * + * The controls hold the change to exactly that rule: a principal the master's + * own door admits for an `update` keeps writing its details, and so does a + * super-user grant and a master that carries none of the three inputs. + * + * Real stack: `ObjectQL` over better-sqlite3 and the real `SecurityPlugin`. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import type { ControlledByParentWriteOutcome } from '@objectstack/spec/contracts'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; + +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as never; +const ORG = 'org_cbp_mow'; +const ME = 'u_cbp_mow_me'; + +/** A master whose posture its own door asks. */ +const MASTER_POSTURE = 'qa_cbp_mow_master_posture'; +/** A master that requires a capability for an update. */ +const MASTER_CAPABILITY = 'qa_cbp_mow_master_capability'; +/** A master that carries none of the three inputs. */ +const MASTER_PLAIN = 'qa_cbp_mow_master_plain'; +/** A master name no registry resolves. */ +const MASTER_UNRESOLVED = 'qa_cbp_mow_master_unresolved'; + +const DETAIL_POSTURE = 'qa_cbp_mow_detail_posture'; +const DETAIL_CAPABILITY = 'qa_cbp_mow_detail_capability'; +const DETAIL_PLAIN = 'qa_cbp_mow_detail_plain'; +const DETAIL_UNRESOLVED = 'qa_cbp_mow_detail_unresolved'; + +const CAPABILITY = 'qa_cbp_mow_capability'; +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } as const; + +const SET = { + readMasters: 'qa_cbp_mow_read_masters', + broad: 'qa_cbp_mow_broad', + superUser: 'qa_cbp_mow_super_user', + editPostureMaster: 'qa_cbp_mow_edit_posture_master', + editCapabilityMaster: 'qa_cbp_mow_edit_capability_master', + capabilityHolder: 'qa_cbp_mow_capability_holder', +} as const; + +type Outcome = { kind: 'landed' } | { kind: 'refused'; code?: string; status?: number }; + +const outcomeOf = (p: Promise): Promise => + p.then( + () => ({ kind: 'landed' as const }), + (e: unknown) => { + const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null; + const status = x?.statusCode ?? x?.status; + return { + kind: 'refused' as const, + code: x?.code == null ? undefined : String(x.code), + status: status == null ? undefined : Number(status), + }; + }, + ); + +const REFUSED = { kind: 'refused', code: 'PERMISSION_DENIED', status: 403 } as const; +const LEG_REFUSAL: ControlledByParentWriteOutcome = { outcome: 'deny', leg: 'object_permission' }; + +/** Release a torn-down rig's async storages (see the explain-enforce-parity suite). */ +const releaseAsyncContexts = (...owners: object[]): void => { + for (const owner of owners) { + for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable(); + } +}; + +const textField = (name: string) => ({ name, type: 'text' }); +const masterOf = (name: string, extra: Record = {}) => ({ + name, + label: name, + sharingModel: 'public_read_write', + ...extra, + fields: { id: { name: 'id', type: 'text', primaryKey: true }, name: textField('name') }, +}); +const detailOf = (name: string, master: string) => ({ + name, + label: name, + sharingModel: 'controlled_by_parent', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + note: textField('note'), + master: { name: 'master', type: 'master_detail', required: true, reference: master }, + }, +}); + +async function boot() { + const 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.cbp-master-object-write', + name: 'A controlled_by_parent detail is written only by a principal its master object admits for an update', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, + masterOf(MASTER_POSTURE, { access: { default: 'private' } }), + masterOf(MASTER_CAPABILITY, { requiredPermissions: { update: [CAPABILITY] } }), + masterOf(MASTER_PLAIN), + detailOf(DETAIL_POSTURE, MASTER_POSTURE), + detailOf(DETAIL_CAPABILITY, MASTER_CAPABILITY), + detailOf(DETAIL_PLAIN, MASTER_PLAIN), + detailOf(DETAIL_UNRESOLVED, MASTER_UNRESOLVED), + ], + } as never); + await engine.syncSchemas(); + + const e = engine as unknown as { + insert: (o: string, row: Record, opts: never) => Promise; + getDriver: (o: string) => { create: (o: string, row: Record) => Promise }; + logger: { warn: () => void }; + }; + await e.insert('sys_organization', { id: ORG, name: ORG, slug: ORG }, SYS); + await e.insert('sys_user', { id: ME, name: ME, email: `${ME}@example.test` }, SYS); + await e.insert('sys_member', { id: `mem_${ME}`, user_id: ME, organization_id: ORG, role: 'member' }, SYS); + + const sets = [ + ...defaultPermissionSets, + PermissionSetSchema.parse({ + name: SET.readMasters, + objects: { + [MASTER_POSTURE]: { allowRead: true }, + [MASTER_CAPABILITY]: { allowRead: true }, + [MASTER_PLAIN]: { allowRead: true }, + }, + }), + PermissionSetSchema.parse({ name: SET.broad, objects: { '*': CRUD } }), + PermissionSetSchema.parse({ + name: SET.superUser, + objects: { '*': { ...CRUD, viewAllRecords: true, modifyAllRecords: true } }, + }), + PermissionSetSchema.parse({ + name: SET.editPostureMaster, + objects: { [MASTER_POSTURE]: { allowRead: true, allowEdit: true }, [DETAIL_POSTURE]: CRUD }, + }), + PermissionSetSchema.parse({ + name: SET.editCapabilityMaster, + objects: { [MASTER_CAPABILITY]: { allowRead: true, allowEdit: true }, [DETAIL_CAPABILITY]: CRUD }, + }), + PermissionSetSchema.parse({ + name: SET.capabilityHolder, + objects: { [MASTER_CAPABILITY]: { allowRead: true, allowEdit: true }, [DETAIL_CAPABILITY]: CRUD }, + systemPermissions: [CAPABILITY], + }), + ]; + + for (const master of [MASTER_POSTURE, MASTER_CAPABILITY, MASTER_PLAIN]) { + await e.insert(master, { id: `${master}_row`, name: 'row' }, SYS); + } + + let security: { checkControlledByParentWrite: (o: string, id: string, c: unknown) => Promise } | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null, + list: async () => sets, + }, + tenancy: { posture: 'single' }, + }; + 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 as typeof security; + else services[name] = svc; + }, + 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); + // The row gates log the writes they refuse. + vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined); + + let n = 0; + return { + engine, + /** A stored detail under `master`, written as SYSTEM (seed replay skips the reference check). */ + seedDetail: async (detail: string, master: string) => { + const id = `${detail}_seed_${n++}`; + await e.getDriver(detail).create(detail, { id, note: 'seeded', master }); + return id; + }, + stored: async (object: string, id: string) => + ((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) ?? null) as Record | null, + checkWrite: (detail: string, id: string, context: unknown) => + security!.checkControlledByParentWrite(detail, id, { ...(context as object) }), + teardown: async () => { + try { await engine.destroy(); } catch { /* noop */ } + releaseAsyncContexts(engine, plugin); + }, + }; +} + +type Rig = Awaited>; + +const member = (...permissions: string[]) => ({ userId: ME, positions: [], permissions, posture: 'MEMBER' }); + +let insertSeq = 0; + +/** Insert, by-id update and by-id delete of one detail under `masterId`, and what each left stored. */ +async function writeDetail(rig: Rig, detail: string, masterId: string, context: unknown) { + const inserted = `${detail}_insert_${insertSeq++}`; + const insert = await outcomeOf( + rig.engine.insert(detail, { id: inserted, note: '', master: masterId }, { context: { ...(context as object) } } as never), + ); + const updated = await rig.seedDetail(detail, masterId); + const update = await outcomeOf( + rig.engine.update(detail, { note: 'written' }, { where: { id: updated }, context: { ...(context as object) } } as never), + ); + const deleted = await rig.seedDetail(detail, masterId); + const del = await outcomeOf(rig.engine.delete(detail, { where: { id: deleted }, context: { ...(context as object) } } as never)); + return { + insert, + update, + delete: del, + insertedStored: (await rig.stored(detail, inserted)) !== null, + updatedNote: (await rig.stored(detail, updated))?.note, + deletedStored: (await rig.stored(detail, deleted)) !== null, + check: await rig.checkWrite(detail, await rig.seedDetail(detail, masterId), context), + }; +} + +const masterUpdate = (rig: Rig, master: string, context: unknown) => + outcomeOf( + rig.engine.update(master, { name: 'written' }, { where: { id: `${master}_row` }, context: { ...(context as object) } } as never), + ); + +describe("a controlled_by_parent detail is not written by a principal its master's own write door refuses at the object gate", () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + describe("on the master's posture", () => { + const context = member(SET.readMasters, SET.broad); + + it("the master's own write door refuses the update, and the principal reads the master (the precondition)", async () => { + expect(await masterUpdate(rig, MASTER_POSTURE, context)).toMatchObject(REFUSED); + const read = await rig.engine.findOne(MASTER_POSTURE, { where: { id: `${MASTER_POSTURE}_row` }, context: { ...context } } as never); + expect(read).toBeTruthy(); + }); + + it('insert, by-id update and by-id delete are refused, and nothing is written', async () => { + const got = await writeDetail(rig, DETAIL_POSTURE, `${MASTER_POSTURE}_row`, context); + expect(got.insert).toMatchObject(REFUSED); + expect(got.update).toMatchObject(REFUSED); + expect(got.delete).toMatchObject(REFUSED); + expect(got.insertedStored).toBe(false); + expect(got.updatedNote).toBe('seeded'); + expect(got.deletedStored).toBe(true); + }); + + it('the master-detail write check refuses on the object_permission leg', async () => { + const id = await rig.seedDetail(DETAIL_POSTURE, `${MASTER_POSTURE}_row`); + expect(await rig.checkWrite(DETAIL_POSTURE, id, context)).toEqual(LEG_REFUSAL); + }); + + it('an insert naming a master id that exists nowhere answers the same refusal', async () => { + expect( + await outcomeOf( + rig.engine.insert(DETAIL_POSTURE, { id: 'qa_cbp_mow_dangling', note: '', master: 'qa_cbp_mow_no_such_master' }, { context: { ...context } } as never), + ), + ).toMatchObject(REFUSED); + }); + }); + + describe('on a capability the master requires for an update', () => { + const context = member(SET.editCapabilityMaster); + + it("the master's own write door refuses the update (the precondition)", async () => { + expect(await masterUpdate(rig, MASTER_CAPABILITY, context)).toMatchObject(REFUSED); + }); + + it('insert, by-id update and by-id delete are refused, and nothing is written', async () => { + const got = await writeDetail(rig, DETAIL_CAPABILITY, `${MASTER_CAPABILITY}_row`, context); + expect(got.insert).toMatchObject(REFUSED); + expect(got.update).toMatchObject(REFUSED); + expect(got.delete).toMatchObject(REFUSED); + expect(got.insertedStored).toBe(false); + expect(got.updatedNote).toBe('seeded'); + expect(got.deletedStored).toBe(true); + expect(got.check).toEqual(LEG_REFUSAL); + }); + }); + + describe("on a master whose posture does not resolve", () => { + const context = member(SET.broad); + + it('an insert is refused, and nothing is written', async () => { + const got = await outcomeOf( + rig.engine.insert(DETAIL_UNRESOLVED, { id: 'qa_cbp_mow_unresolved_insert', note: '', master: 'qa_cbp_mow_any' }, { context: { ...context } } as never), + ); + expect(got).toMatchObject(REFUSED); + expect(await rig.stored(DETAIL_UNRESOLVED, 'qa_cbp_mow_unresolved_insert')).toBeNull(); + }); + + it('the master-detail write check refuses on the object_permission leg', async () => { + const id = await rig.seedDetail(DETAIL_UNRESOLVED, 'qa_cbp_mow_any'); + expect(await rig.checkWrite(DETAIL_UNRESOLVED, id, context)).toEqual(LEG_REFUSAL); + }); + }); +}); + +describe("a principal its master's own write door admits for an update keeps writing the master's details", () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + const LANDS = { insert: { kind: 'landed' }, update: { kind: 'landed' }, delete: { kind: 'landed' } } as const; + const WRITTEN = { insertedStored: true, updatedNote: 'written', deletedStored: false, check: { outcome: 'allow' } } as const; + + const CONTROLS = [ + { label: 'an object-level edit grant on the master', context: member(SET.editPostureMaster), master: MASTER_POSTURE, detail: DETAIL_POSTURE }, + { label: 'a super-user grant', context: member(SET.superUser), master: MASTER_POSTURE, detail: DETAIL_POSTURE }, + { label: 'the required capability with an edit grant', context: member(SET.capabilityHolder), master: MASTER_CAPABILITY, detail: DETAIL_CAPABILITY }, + { label: 'a master that carries none of the three inputs', context: member(SET.readMasters, SET.broad), master: MASTER_PLAIN, detail: DETAIL_PLAIN }, + ] as const; + + for (const { label, context, master, detail } of CONTROLS) { + it(`${label}: the master's own door admits the update, and insert, by-id update and by-id delete of a detail land`, async () => { + expect(await masterUpdate(rig, master, context)).toEqual({ kind: 'landed' }); + const got = await writeDetail(rig, detail, `${master}_row`, context); + expect(got).toMatchObject({ ...LANDS, ...WRITTEN }); + }); + } + + it('a system context writes a detail under every master', async () => { + for (const [detail, master] of [ + [DETAIL_POSTURE, `${MASTER_POSTURE}_row`], + [DETAIL_CAPABILITY, `${MASTER_CAPABILITY}_row`], + [DETAIL_UNRESOLVED, 'qa_cbp_mow_any'], + ] as const) { + const id = `${detail}_system`; + expect(await outcomeOf(rig.engine.insert(detail, { id, note: '', master }, SYS))).toEqual({ kind: 'landed' }); + expect(await rig.stored(detail, id)).toBeTruthy(); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a774fe4e1dd..7dbb423417f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -9288,7 +9288,35 @@ export class SecurityPlugin implements Plugin { ): Promise { // Master edit access = CRUD update on the master AND the master row reachable // under BOTH halves of its own write gate (write RLS + record sharing). - if (!this.permissionEvaluator.checkObjectPermission('update', rel.master, permissionSets)) { + // + // [ADR-0055 §3] The object-level half asks the question the master's own + // write door asks of an `update` on the master, with that door's own + // inputs, read from the one source each door reads them from + // ({@link getObjectSecurityMeta}): the posture must resolve (#3545 — an + // unresolved posture would read as `isPrivate: false`), the capabilities + // the master requires for `update` must be held (ADR-0066 D3/⑤), and the + // CRUD grant is asked under the master's posture (ADR-0066 D2). The leg + // used to ask the CRUD grant alone, with no posture, so a principal the + // master's own door refuses at its object gate could still write the + // master's details. Every refusal here is this leg's, `object_permission`. + // ⛔ Nothing here re-derives a rule: each is the evaluator's or the + // posture's own answer, asked as the door asks it. + const masterPosture = await this.getObjectSecurityMeta(rel.master); + if (masterPosture.unresolved) { + denyMasterEdit('object_permission', `the security posture of master '${rel.master}' could not be resolved`, masterId); + } + const masterUpdateCaps = requiredCapsForOperation(masterPosture.requiredPermissions, 'update'); + if (masterUpdateCaps.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + if (masterUpdateCaps.some((cap) => !held.has(cap))) { + denyMasterEdit('object_permission', `master '${rel.master}' requires a capability this user does not hold`, masterId); + } + } + if ( + !this.permissionEvaluator.checkObjectPermission('update', rel.master, permissionSets, { + isPrivate: masterPosture.isPrivate, + }) + ) { denyMasterEdit('object_permission', `no edit permission on master '${rel.master}'`, masterId); } // [commit 498f4e884] The master's own write RLS — composed with the SAME ownership From ac70f18533676d9526f5dc8422e35590f5716121 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:56:04 +0000 Subject: [PATCH 2/3] docs(changeset): name the insert envelopes the master-edit leg now answers first Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24986-cbp-master-edit-object-question.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24986-cbp-master-edit-object-question.md b/.changeset/24986-cbp-master-edit-object-question.md index af0b93a1ddd..10ea6e072af 100644 --- a/.changeset/24986-cbp-master-edit-object-question.md +++ b/.changeset/24986-cbp-master-edit-object-question.md @@ -9,5 +9,5 @@ Clause-②: no ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master. The master-edit gate's object-level leg asked only for an object-level `update` grant on the master. It now asks what the master's own write door asks of an `update` on the master, with that door's inputs: the master's security posture must resolve, the capabilities the master requires for `update` (ADR-0066 D3) must be held, and the grant is asked under the master's posture (ADR-0066 D2). A principal the master's own write door refuses at its object gate can no longer write a detail under that master. - **Every door that asks the leg.** An insert, a by-id update and a by-id delete of a detail answer `PERMISSION_DENIED` (403) and write nothing. `checkControlledByParentWrite`, which the comment and attachment parent gates ask, answers `deny` on the `object_permission` leg. A record-grained `security/explain` of a by-id write follows the same leg. -- **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. +- **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. A principal that also cannot read the master, on an on-behalf-of write too, now gets this `PERMISSION_DENIED` on insert where it got the reference check's `VALIDATION_FAILED`. An insert under a master object that does not resolve answers `PERMISSION_DENIED` where it answered `OBJECT_NOT_FOUND` (404). - **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before. Predicate (bulk) writes are not this leg's: they stay scoped by the detail's read scope. From 85f0113f1e418e851a8d429ee024af7c919dfcc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:51:55 +0000 Subject: [PATCH 3/3] docs(changeset): keep the changeset to the leg it changes Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24986-cbp-master-edit-object-question.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24986-cbp-master-edit-object-question.md b/.changeset/24986-cbp-master-edit-object-question.md index 10ea6e072af..06da031be41 100644 --- a/.changeset/24986-cbp-master-edit-object-question.md +++ b/.changeset/24986-cbp-master-edit-object-question.md @@ -10,4 +10,4 @@ ADR-0055 §3 says editing, deleting or creating a detail requires edit access to - **Every door that asks the leg.** An insert, a by-id update and a by-id delete of a detail answer `PERMISSION_DENIED` (403) and write nothing. `checkControlledByParentWrite`, which the comment and attachment parent gates ask, answers `deny` on the `object_permission` leg. A record-grained `security/explain` of a by-id write follows the same leg. - **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. A principal that also cannot read the master, on an on-behalf-of write too, now gets this `PERMISSION_DENIED` on insert where it got the reference check's `VALIDATION_FAILED`. An insert under a master object that does not resolve answers `PERMISSION_DENIED` where it answered `OBJECT_NOT_FOUND` (404). -- **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before. Predicate (bulk) writes are not this leg's: they stay scoped by the detail's read scope. +- **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before.