From 04e42a7b0662222c7906f9e6bdafe32096567550 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:37:33 +0000 Subject: [PATCH 1/2] fix(plugin-security): a write refused for a caller who cannot see the row answers the same for a hidden row and a missing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0090 D12 administration gate answers the by-id update or delete of a caller whose read of the assignment object is refused with that read refusal; a caller who reads the object keeps the gate's own sentence. Clause-②: no Metadata-protocol: none Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/write-refusal-read-door-parity.md | 12 ++ .../docs/protocol/kernel/http-protocol.mdx | 6 +- .../by-id-write-unreadable-not-found.test.ts | 184 ++++++++++++++++++ .../src/security-plugin-authz-middleware.ts | 67 ++++++- ...update-by-id-refusal-order.dogfood.test.ts | 72 ++++--- 5 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 .changeset/write-refusal-read-door-parity.md diff --git a/.changeset/write-refusal-read-door-parity.md b/.changeset/write-refusal-read-door-parity.md new file mode 100644 index 00000000000..4ed4d3b9b1a --- /dev/null +++ b/.changeset/write-refusal-read-door-parity.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a write refused for a caller who cannot see the row answers the same for a hidden row and a missing one + +Clause-②: no +Metadata-protocol: none + +The administration gate on the permission-assignment objects (`sys_user_permission_set`, `sys_user_position`, ADR-0090 D12) refuses a write before the object's grants are read, in words written for the write. For a by-id update or delete, a caller whose read of the object is refused now gets that read refusal from it instead — the answer its by-id read gives for every id — so a row and an id naming none answer the same status, code and sentence, with a version token (`If-Match`, `expectedVersion`) and without one. + +- **Unchanged:** a caller who reads the object keeps the gate's own sentence (an organization administrator's "never edited in place", the administration hint for a holder of plain grants); a tenant-level administrator writes as before; an insert, a predicate write and a write the platform issues under the caller's context keep the gate's own refusal. Every answer is still a refusal: nothing that was refused is admitted, and nothing admitted is refused. diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 246e03db861..2c6caf30e47 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -757,7 +757,11 @@ A version token (`If-Match`, or `expectedVersion`) is compared only against a re caller can read: a stale token on such a record is answered `409 CONCURRENT_UPDATE`, whether or not the caller may write it. For a record the caller's read does not return, and for an id that names no record, a stale token changes none of the answers above. A -caller refused reading the object gets that read refusal. +caller refused reading the object gets that read refusal — and so does the same caller's +update or delete of a permission assignment (`sys_user_permission_set`, +`sys_user_position`), which is refused before the object's grants are read: for any +`{record_id}`, with or without a token. A caller who reads the assignment object gets +the administration refusal's own sentence instead. ### Delete Record diff --git a/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts b/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts index 31915c2bde0..5ba594e5c9c 100644 --- a/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts +++ b/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts @@ -19,6 +19,13 @@ * write — are not addressed by the caller, so they keep the answer they * had before: the record-level 403, which names nothing. A not-found there * would carry an id the caller never supplied. + * 5. The ADR-0090 D12 administration gate, which refuses a write to the + * permission-assignment objects before any grant is read, answers the + * addressed by-id update and delete of a caller whose read of the object + * is refused with that read refusal, for a row and for an id naming none + * alike. A caller who reads the object, and an insert, keep the gate's own + * sentence; an administrator still writes. (Its own rig, at the foot of + * the file: the two assignment objects and three more permission sets.) * * The rig is one permission set over two objects: a public parent, and a * child whose rows a caller reads when it owns them or they are flagged @@ -35,6 +42,10 @@ import { recordNotFoundError } from '@objectstack/core'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -253,3 +264,176 @@ describe('a by-id write the platform issues under the caller\'s context keeps th expect(await r.remove(CHILD, 'c_kid')).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); }); }); + + +// ── The delegated-administration gate (ADR-0090 D12) ───────────────────────── +// +// The gate refuses a write to the permission-assignment objects on the caller +// and the object alone, before any grant is read, in its own words. For a +// by-id update or delete the caller addressed, a caller whose read of the +// object is refused gets the read door's refusal from it instead: the answer +// that caller's by-id read gives for every id, so a row and an id naming none +// answer alike. A caller who reads the object keeps the gate's own sentence, +// an insert (which names no row) keeps it too, and an administrator still +// writes. + +const ASSIGNMENT_OBJECTS = ['sys_user_permission_set', 'sys_user_position'] as const; +type AssignmentObject = (typeof ASSIGNMENT_OBJECTS)[number]; +const SEEDED_ROW: Record = { sys_user_permission_set: 'ups_seeded', sys_user_position: 'upos_seeded' }; +const NOWHERE = 'zz_nowhere_0001'; +const GATE_SENTENCE = 'requires tenant-level administration or a delegated adminScope'; +const ADMIN_SET = defaultPermissionSets.find((p) => p.name === ADMIN_FULL_ACCESS)!; + +/** Holds no grant on either object (its one set names the two business objects above). */ +const NO_GRANT = { userId: 'usr_nf_nogrant', positions: [], permissions: ['qa_nf_guard'], posture: 'MEMBER' }; +/** May create, update and delete both objects, and read neither. */ +const WRITE_NO_READ = { userId: 'usr_nf_writer', positions: [], permissions: ['qa_nf_blind_writer'], posture: 'MEMBER' }; +/** Reads both objects, and holds nothing else on them. */ +const READER = { userId: 'usr_nf_reader', positions: [], permissions: ['qa_nf_reader'], posture: 'MEMBER' }; +const ADMIN = { userId: 'usr_nf_admin', positions: [], permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' }; + +const gateEngines: ObjectQL[] = []; +afterEach(async () => { + for (const engine of gateEngines.splice(0)) { + try { await engine.destroy(); } catch { /* noop */ } + } +}); + +type Read = { kind: 'read'; row: unknown } | ({ kind: 'refused' } & Refusal); + +interface GateRig { + read: (caller: object, object: string, id: string) => Promise; + write: (caller: object, verb: 'update' | 'delete', object: string, id: string) => Promise; + insert: (caller: object, object: string, row: Record) => Promise; + stored: (object: string, id: string) => Promise | null>; +} + +async function bootGateRig(): Promise { + 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.by-id-write-administration-gate', + name: 'By-id write: the administration gate answers a caller who cannot read', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUserPermissionSet, SysUserPosition], + } as never); + await engine.syncSchemas(); + // `sys_user` registered and unprovisioned, as in the sibling suites: the + // assignment rows' title formula reads through it. + if (!engine.registry.getObject(SysUser.name)) engine.registry.registerObject(SysUser as never, 'qa.by-id-write-administration-gate'); + await installDefaultOrganization(engine); + await engine.insert('sys_user_permission_set', { id: SEEDED_ROW.sys_user_permission_set, user_id: OTHER, permission_set: 'qa_nf_guard' }, SYS); + await engine.insert('sys_user_position', { id: SEEDED_ROW.sys_user_position, user_id: OTHER, position: 'qa_pos' }, SYS); + gateEngines.push(engine); + + const grant = (allowRead: boolean, write: boolean) => + Object.fromEntries(ASSIGNMENT_OBJECTS.map((o) => [o, { allowRead, allowCreate: write, allowEdit: write, allowDelete: write }])); + const sets = [ + MEMBER_DEFAULT, + ADMIN_SET, + PermissionSetSchema.parse({ + name: 'qa_nf_guard', + objects: { + [PARENT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + [CHILD]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, + }), + PermissionSetSchema.parse({ name: 'qa_nf_blind_writer', objects: grant(false, true) }), + PermissionSetSchema.parse({ name: 'qa_nf_reader', objects: grant(true, false) }), + ]; + 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, + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + 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); + + return { + read: (caller, object, id) => + engine.findOne(object, { where: { id }, context: { ...caller } } as never).then( + (row): Read => ({ kind: 'read', row }), + (e: any): Read => ({ kind: 'refused', code: e?.code, status: e?.statusCode ?? e?.status, message: String(e?.message), name: e?.name }), + ), + write: (caller, verb, object, id) => + outcome( + verb === 'update' + ? engine.update(object, { reason: 'renamed' }, { where: { id }, context: { ...caller } } as never) + : engine.delete(object, { where: { id }, context: { ...caller } } as never), + ), + insert: (caller, object, row) => outcome(engine.insert(object, row, { context: { ...caller } } as never)), + stored: async (object, id) => + ((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) ?? null) as Record | null, + }; +} + +describe('the administration gate answers a by-id write from a caller who cannot read the object with that read\'s refusal', () => { + for (const object of ASSIGNMENT_OBJECTS) { + for (const verb of ['update', 'delete'] as const) { + for (const [label, caller] of [['no grant on the object', NO_GRANT], ['a write grant without a read grant', WRITE_NO_READ]] as const) { + it(`${object} ${verb}, ${label}: the row and an id naming none get the read door's refusal`, async () => { + const r = await bootGateRig(); + const id = SEEDED_ROW[object]; + const before = await r.stored(object, id); + // The precondition, read off the read door: it refuses this caller. + const read = await r.read(caller, object, id); + expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + + const row = await r.write(caller, verb, object, id); + const none = await r.write(caller, verb, object, NOWHERE); + + expect(row, 'the row').toEqual(read); + expect(none, 'an id naming none').toEqual(read); + expect(await r.stored(object, id), 'refused means untouched').toEqual(before); + }); + } + + it(`${object} ${verb}, a caller who reads the object: the row and an id naming none keep the gate's own refusal`, async () => { + const r = await bootGateRig(); + const id = SEEDED_ROW[object]; + expect(await r.read(READER, object, id)).toMatchObject({ kind: 'read', row: { id } }); + + const row = await r.write(READER, verb, object, id); + const none = await r.write(READER, verb, object, NOWHERE); + + expect(row).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(row.kind === 'refused' && row.message).toContain(GATE_SENTENCE); + expect(withoutId(none, NOWHERE)).toEqual(withoutId(row, id)); + }); + } + } + + it('an administrator still updates an assignment row, and gets not-found for an id naming none', async () => { + const r = await bootGateRig(); + expect(await r.write(ADMIN, 'update', 'sys_user_permission_set', SEEDED_ROW.sys_user_permission_set)).toEqual({ kind: 'landed' }); + expect((await r.stored('sys_user_permission_set', SEEDED_ROW.sys_user_permission_set))?.reason).toBe('renamed'); + expect(await r.write(ADMIN, 'update', 'sys_user_permission_set', NOWHERE)).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); + }); + + it('an insert names no row, so a caller who cannot read the object keeps the gate\'s own refusal', async () => { + const r = await bootGateRig(); + const got = await r.insert(NO_GRANT, 'sys_user_permission_set', { user_id: OTHER, permission_set: 'qa_nf_guard' }); + expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(got.kind === 'refused' && got.message).toContain(GATE_SENTENCE); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts index c8848398c8e..5b65837cde2 100644 --- a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts +++ b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts @@ -7,7 +7,9 @@ * through. The handler retires the permission-set memo on a write, refuses a * flow run's identity-assignment writes, lets every other system operation * through, and serves the ADR-0056 public-form grant. It then runs the - * engine-owned write guard and the ADR-0090 D12 delegated-administration gate, + * engine-owned write guard and the ADR-0090 D12 delegated-administration gate + * (whose refusal of a by-id write answers a caller the read door refuses with + * that read refusal, `readDoorAnswerForGateRefusal`), * resolves the operation's principals and the object's posture, and runs the * numbered steps in the order they stand: the `requiredPermissions` AND-gate * (1.5), the CRUD check (2), the access depth and an external principal's @@ -97,6 +99,7 @@ import { renderOperationMessage } from '@objectstack/spec/system'; import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from '@objectstack/metadata-core'; import type { II18nService, IMetadataService, IObjectQLEngine, SharingWriteVerdict } from '@objectstack/spec/contracts'; import { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from '@objectstack/formula'; +import { declaredHttpStatus } from '@objectstack/types'; import type { SecurityPlugin } from './security-plugin.js'; import { isGuestEnvelope, requiredCapsForOperation } from './security-plugin.js'; import { assertFlowSystemIdentityWriteAllowed } from './flow-system-identity-write-refusal.js'; @@ -228,6 +231,58 @@ function userFacingDenialMessage( return renderOperationMessage({ messageKey }, { locale, translate }); } +/** + * [ADR-0090 D12; ruling of record 5985885287] What a by-id write the + * delegated-administration gate refused answers a caller the read door + * refuses: that door's refusal. + * + * The gate judges the caller and the object before any grant is read and + * before any row is, and words its refusal for the write. The by-id write + * doors answer a caller who cannot read a row what they answer for an id + * naming no row (ruling of record 5985885287), and where a write is answered + * from the caller's own read of its row — the by-id version comparison answers + * a mismatched token with that read's refusal (`assertVersionOf`, + * `@objectstack/metadata-protocol`) — a caller the read door refuses is + * answered that refusal. So the gate answers that caller the same way: the + * read door's own question is asked of the id the caller addressed + * ({@link SecurityPlugin.getCallerPreImage}: the caller's by-id read through + * the engine, never a second evaluator), and + * + * - the read is REFUSED (a declared 4xx): that refusal is the answer. It is + * the read door's answer to this caller for any id, so the row and an id + * naming none answer alike, with a version token and without; + * - the read ANSWERS (a row, or none): the caller reads the object, and the + * gate's own refusal stands, its guidance intact; + * - the read FAULTS: the fault propagates as raised (#7505) — an outage is + * neither an answer nor a refusal. + * + * Asked only of a refusal (a declared 4xx; anything else the gate throws stays + * what it is), only for the by-id update or delete the caller addressed (an + * insert, a predicate write and a nested write name no row of the caller's — + * {@link SecurityPlugin.addressedByIdWriteId}), and never for a row-write + * probe, whose verdict is admitted or refused and nothing more. + */ +async function readDoorAnswerForGateRefusal( + this: Pick, + opCtx: any, + refusal: unknown, + nestedInOperation: boolean, +): Promise { + const status = declaredHttpStatus(refusal); + if (status === undefined || status >= 500 || isRowWriteProbe(opCtx)) return refusal; + const referentialFieldClear = opCtx.operation === 'update' && opCtx.context?.__referentialFieldClear === true; + const addressedId = this.addressedByIdWriteId(opCtx, nestedInOperation, referentialFieldClear); + if (addressedId === undefined) return refusal; + try { + await this.getCallerPreImage(opCtx, addressedId); + } catch (readRefusal) { + const readStatus = declaredHttpStatus(readRefusal); + if (readStatus !== undefined && readStatus < 500) return readRefusal; + throw readRefusal; + } + return refusal; +} + /** * The `SecurityPlugin` members the function below reads — every one of them, * and no other; the function's `this` is the `Pick` of the members it reads. @@ -485,8 +540,16 @@ export function registerAuthzMiddleware( // else — including holders of plain CRUD grants on these tables — is // denied. Runs BEFORE the principal-less refusal below so RBAC // tables fail CLOSED for principal-less non-system contexts. + // + // It judges before any grant is read, in its own words; on the by-id + // update or delete the caller addressed, a caller the read door refuses + // is answered that read refusal instead — {@link readDoorAnswerForGateRefusal}. if (this.delegatedAdminGate) { - await this.delegatedAdminGate.assert(opCtx); + try { + await this.delegatedAdminGate.assert(opCtx); + } catch (refusal) { + throw await readDoorAnswerForGateRefusal.call(this, opCtx, refusal, nestedInOperation); + } } const positions = opCtx.context?.positions ?? []; diff --git a/packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts index 2a821c1d452..a0053d29989 100644 --- a/packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts +++ b/packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts @@ -25,10 +25,10 @@ // row and without a token: the version comparison hands a row absent // from the caller's read to the write door. On the two objects the member // may neither read nor write, its read is REFUSED, and a refused read is -// not handed over: the row, no row and no token answer the same status -// and code. Their sentences are not compared — the read door's refusal -// and the write door's may word it differently, a residue aligned by its -// own item. +// not handed over — and the write door answers a caller the read door +// refuses that same refusal, the administration gate on the platform +// grant object included: there too the row, no row and no token answer the +// same status, code and sentence. // // Controls, so a green run is not "everything refuses": // @@ -45,6 +45,9 @@ // - with a stale token the edit-grant holder gets `409 CONCURRENT_UPDATE` // carrying the row, and with the current token it updates (200); the plain // member gets the same `409` on a row it reads but may not write; +// - an organization administrator, who reads the platform grant object, keeps +// the administration gate's own sentence for an edit in place, for a row +// and for no row; // - a refused write leaves the row untouched. // // The organization wall's half of item 4 — a row in another organization — is @@ -67,12 +70,19 @@ const SYS = { isSystem: true } as const; const MISSING = 'zz_no_such_row_0001'; const MEMBER = 'upd-order-member@verify.test'; const EDITOR = 'upd-order-editor@verify.test'; +const ORG_ADMIN = 'upd-order-org-admin@verify.test'; interface Answer { status: number; body: unknown } /** The `code` of a flat (`{ code }`) or nested (`{ error: { code } }`) envelope. */ const codeOf = (a: Answer): unknown => (a.body as any)?.code ?? (a.body as any)?.error?.code; +/** The sentence of a flat (`{ error: '…' }`) or nested (`{ error: { message } }`) envelope. */ +const sentenceOf = (a: Answer): string => { + const error = (a.body as any)?.error; + return String(typeof error === 'string' ? error : error?.message); +}; + /** The answer with the caller-supplied id written out, so the answers for two ids compare. */ const withoutId = (a: Answer, id: string): Answer => JSON.parse(JSON.stringify(a).split(id).join('ID')) as Answer; @@ -103,6 +113,7 @@ describe('update by id: one refusal whether or not the id exists, as delete', () let adminTok: string; let memberTok: string; let editorTok: string; + let orgAdminTok: string; const existing = new Map(); let foreignNoteId = ''; let ownNoteId = ''; @@ -166,6 +177,12 @@ describe('update by id: one refusal whether or not the id exists, as delete', () const editorId = (await ql.findOne('sys_user', { where: { email: EDITOR }, context: SYS }))?.id; expect(editorId, 'editor provisioned').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: editorId, permission_set: ops.name }, { context: SYS }); + orgAdminTok = await stack.signUp(ORG_ADMIN); + const orgAdmin = await catalogPermissionSet(ql, 'organization_admin'); + expect(orgAdmin, 'organization_admin served by the security catalog').toBeTruthy(); + const orgAdminId = (await ql.findOne('sys_user', { where: { email: ORG_ADMIN }, context: SYS }))?.id; + expect(orgAdminId, 'organization administrator provisioned').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: orgAdminId, permission_set: orgAdmin.name }, { context: SYS }); for (const object of Object.keys(PATCHES)) { const id = String((await ql.find(object, { limit: 1, context: SYS }))?.[0]?.id ?? ''); @@ -243,41 +260,52 @@ describe('update by id: one refusal whether or not the id exists, as delete', () }); it('with a stale version token, PATCH and DELETE answer one answer for a row and for no row — the answer without a token', async () => { - /** - * `whole`: status, code and sentence (the row is absent from the member's - * read, so the write door answers). `status+code`: the member may not read - * the object, its refused read is the answer, and the sentence is - * deliberately not compared. - */ - type Compare = 'whole' | 'status+code'; - const families: Array<{ object: string; id: string; patch: Record; status: { PATCH: number; DELETE: number }; compare: Compare }> = [ + // Status, code and sentence, every family: where the row is absent from + // the member's read the write door answers, and where the member may not + // read the object at all the read door's refusal is the answer for the + // row, for no row and without a token alike. + const families: Array<{ object: string; id: string; patch: Record; status: { PATCH: number; DELETE: number } }> = [ // Objects the member may neither read nor write. - { object: 'showcase_project_membership', id: existing.get('showcase_project_membership')!, patch: PATCHES.showcase_project_membership, status: { PATCH: 403, DELETE: 403 }, compare: 'status+code' }, - { object: 'sys_user_permission_set', id: existing.get('sys_user_permission_set')!, patch: PATCHES.sys_user_permission_set, status: { PATCH: 403, DELETE: 403 }, compare: 'status+code' }, + { object: 'showcase_project_membership', id: existing.get('showcase_project_membership')!, patch: PATCHES.showcase_project_membership, status: { PATCH: 403, DELETE: 403 } }, + { object: 'sys_user_permission_set', id: existing.get('sys_user_permission_set')!, patch: PATCHES.sys_user_permission_set, status: { PATCH: 403, DELETE: 403 } }, // Rows the member's row scope hides, on objects it reads: no update grant on // the inquiry, and update without delete on the note. - { object: 'showcase_inquiry', id: foreignInquiryId, patch: { message: 'x' }, status: { PATCH: 403, DELETE: 403 }, compare: 'whole' }, - { object: 'showcase_private_note', id: foreignNoteId, patch: { title: 'x' }, status: { PATCH: 404, DELETE: 403 }, compare: 'whole' }, + { object: 'showcase_inquiry', id: foreignInquiryId, patch: { message: 'x' }, status: { PATCH: 403, DELETE: 403 } }, + { object: 'showcase_private_note', id: foreignNoteId, patch: { title: 'x' }, status: { PATCH: 404, DELETE: 403 } }, ]; - const shown = (a: Answer, id: string, compare: Compare): unknown => - compare === 'whole' ? withoutId(a, id) : { status: a.status, code: codeOf(a) }; - for (const { object, id, patch, status, compare } of families) { + for (const { object, id, patch, status } of families) { const before = await stored(object, id); for (const method of ['PATCH', 'DELETE'] as const) { const noToken = await versioned(memberTok, method, object, id, patch, 'none'); expect(noToken.status, `${method} ${object} without a token — ${JSON.stringify(noToken.body)}`).toBe(status[method]); for (const carrier of ['If-Match', 'expectedVersion'] as const) { - const where = `${method} ${object} (${carrier}, ${compare})`; + const where = `${method} ${object} (${carrier})`; const hit = await versioned(memberTok, method, object, id, patch, carrier); const miss = await versioned(memberTok, method, object, MISSING, patch, carrier); - expect(shown(hit, id, compare), `${where}: the answer without a token`).toEqual(shown(noToken, id, compare)); - expect(shown(miss, MISSING, compare), `${where}: the answer for no row`).toEqual(shown(hit, id, compare)); + expect(withoutId(hit, id), `${where}: the answer without a token`).toEqual(withoutId(noToken, id)); + expect(withoutId(miss, MISSING), `${where}: the answer for no row`).toEqual(withoutId(hit, id)); } } expect(await stored(object, id), `${object}: refused writes leave the row as it was`).toEqual(before); } }); + it('control: an organization administrator, who reads the platform grant object, keeps the administration gate\'s sentence for a row and for no row', async () => { + const object = 'sys_user_permission_set'; + const id = existing.get(object)!; + const before = await stored(object, id); + const read = await call(orgAdminTok, 'GET', `/data/${object}/${id}`); + expect(read.status, `the organization administrator reads the row — ${JSON.stringify(read.body)}`).toBe(200); + + const hit = await call(orgAdminTok, 'PATCH', `/data/${object}/${id}`, PATCHES[object]); + const miss = await call(orgAdminTok, 'PATCH', `/data/${object}/${MISSING}`, PATCHES[object]); + + expect({ status: hit.status, code: codeOf(hit) }, JSON.stringify(hit.body)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(sentenceOf(hit), 'the gate\'s own sentence').toContain('never edited in place'); + expect(withoutId(miss, MISSING), 'the answer for no row').toEqual(withoutId(hit, id)); + expect(await stored(object, id), 'a refused edit leaves the row as it was').toEqual(before); + }); + it('control: a stale token on a row the caller reads still answers 409 carrying the row; the current token updates', async () => { const id = existing.get('showcase_project_membership')!; const read = await call(editorTok, 'GET', `/data/showcase_project_membership/${id}`); From 7e516c3dcdf34536b4aa39aa1e25b3b1e604a06f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:36:54 +0000 Subject: [PATCH 2/2] docs(changeset): state the rule for the caller class the gate change covers Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/write-refusal-read-door-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/write-refusal-read-door-parity.md b/.changeset/write-refusal-read-door-parity.md index 4ed4d3b9b1a..d27548992b9 100644 --- a/.changeset/write-refusal-read-door-parity.md +++ b/.changeset/write-refusal-read-door-parity.md @@ -2,7 +2,7 @@ '@objectstack/plugin-security': patch --- -fix(plugin-security): a write refused for a caller who cannot see the row answers the same for a hidden row and a missing one +fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one Clause-②: no Metadata-protocol: none