From e9dff03eb1c8fe4915e3c84c1c7007ed273f522b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:56:41 +0000 Subject: [PATCH 1/6] fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does A by-id update or delete of an assignment row that the organization administrator or the delegate does not read answers the read door's not-found for that object and id; a refusal names only values the caller can read. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/delegated-admin-gate.ts | 220 +++++++++++++++--- .../src/security-plugin-authz-middleware.ts | 27 ++- 2 files changed, 211 insertions(+), 36 deletions(-) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 78e3ddaff53..cb514e84ecc 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -355,6 +355,12 @@ function isTenantLevelAdmin(sets: PermissionSet[]): boolean { interface OrganizationFailure { reason: string; meta: Record; + /** + * The by-id update or delete addresses no row the caller's own read returns + * — refused with {@link UnreadableAddressedRowRefusal}, not the boundary's + * refusal. + */ + unreadable?: boolean; } /** Single scalar id from an update/delete opCtx (mirrors the engine's single-id rule). */ @@ -397,6 +403,80 @@ function refusalFor( ); } +/** + * [ADR-0090 D12; ruling of record 5985885287] The gate's refusal of a by-id + * update or delete whose row the caller's own read does not return: an id + * naming no row and a row the caller cannot read, refused alike. + * + * Its own class only so the security middleware can tell it from every other + * refusal of the gate. For the by-id write the caller addressed, the + * middleware answers it the way the read door and step 2.7 answer every other + * object — `recordNotFoundError` for that object and id + * (`readDoorAnswerForGateRefusal`). Wherever no row was addressed — a cascade's + * or a hook's nested write, the write-admission preview — it stays this + * refusal: `403 PERMISSION_DENIED`, naming no row and no id. + */ +class UnreadableAddressedRowRefusal extends PermissionDeniedError {} + +/** Is `refusal` the gate's {@link UnreadableAddressedRowRefusal}? */ +export function isUnreadableAddressedRowRefusal(refusal: unknown): boolean { + return refusal instanceof UnreadableAddressedRowRefusal; +} + +/** + * [ADR-0090 D12] A row a delegated write targets, with what the caller may be + * told about it: `prev` the stored row a by-id write addresses, `next` the row + * an insert or an update writes, and the two column sets a refusal's guidance + * reads ({@link mayName}). + */ +interface DelegatedTarget { + next: any | null; + prev: any | null; + /** + * The columns whose `next` value the caller supplied in its own payload; + * `null` for an insert, whose every value is the caller's. + */ + wrote: ReadonlySet | null; + /** + * The columns of `prev` the caller's own read returned at their stored + * value ({@link columnsReadAsStored}). + */ + reads: ReadonlySet; +} + +/** + * [ruling of record 5985885287, sub-item B] The columns of the stored row + * `prev` that the caller's own read `seen` returned at their stored value. + * + * The read is the read door's answer to this caller, field rules and masking + * included, so a column a field rule hides is absent from it and a masked one + * differs: neither is here. Strict equality, so a value read in any other form + * counts as not read. No read (`null`), no column. + */ +function columnsReadAsStored(seen: any | null, prev: any | null): Set { + const out = new Set(); + if (!seen || typeof seen !== 'object' || !prev || typeof prev !== 'object') return out; + for (const [column, value] of Object.entries(prev)) { + if (Object.prototype.hasOwnProperty.call(seen, column) && seen[column] === value) out.add(column); + } + return out; +} + +/** + * [ruling of record 5985885287, sub-item B] May a refusal of `t` name the + * value of `column` on its `side`? + * + * Only a value the caller already has: one it wrote (an insert's row, a + * column its update payload carries), or one its own read returned at the + * stored value. The verdict is still taken on the stored row; only the + * wording follows this, and a value it may not name is left out of the + * sentence and out of the details. + */ +function mayName(t: DelegatedTarget, side: 'next' | 'prev', column: string): boolean { + if (side === 'next' && (t.wrote === null || t.wrote.has(column))) return true; + return t.reads.has(column); +} + export class DelegatedAdminGate { constructor(private readonly deps: DelegatedAdminGateDeps) {} @@ -505,7 +585,8 @@ export class DelegatedAdminGate { const held = await this.resolveHeldScopes(sets, organizationId); if (held.length === 0 && organizationFailure) { - throw new PermissionDeniedError( + const Refusal = organizationFailure.unreadable ? UnreadableAddressedRowRefusal : PermissionDeniedError; + throw new Refusal( `[Security] Access denied: organization administrator '${opCtx.operation}' on '${opCtx.object}' ` + `rejected — ${organizationFailure.reason} (ADR-0090 D12, the organization boundary).`, { operation: opCtx.operation, object: opCtx.object, ...organizationFailure.meta }, @@ -768,7 +849,11 @@ export class DelegatedAdminGate { * * The row a removal targets is judged as the CALLER reads it * ({@link organizationTargets}), so a row the caller cannot read is answered - * exactly as an id naming no row is (ruling of record 5985885287). + * exactly as an id naming no row is (ruling of record 5985885287). A by-id + * edit, which the right never admits, is asked the same question first: an + * edit of a row the caller cannot read, or of an id naming none, is refused + * as no row, not as an edit — `unreadable`, which the security middleware + * answers with the read door's not-found. */ private async organizationAdminFailure( opCtx: any, @@ -778,6 +863,12 @@ export class DelegatedAdminGate { if (!organizationId) { return { reason: 'the request carries no active organization to administer', meta: {} }; } + if (op === 'update') { + const id = extractSingleId(opCtx); + if (id != null && (await this.hiddenFromCaller(opCtx, id))) { + return { reason: 'the assignment row it addresses could not be read', meta: {}, unreadable: true }; + } + } if (op !== 'insert' && op !== 'delete') { return { reason: `'${op}' is not part of the right — an assignment row is created and removed, never edited in place`, @@ -795,7 +886,7 @@ export class DelegatedAdminGate { const row = t.next ?? t.prev; // No row answers an id naming none and a row the caller cannot read // alike: one refusal, and never a fall-through to the engine. - if (!row) return { reason: 'the assignment row to remove could not be read', meta: {} }; + if (!row) return { reason: 'the assignment row to remove could not be read', meta: {}, unreadable: op === 'delete' }; const rowOrg = rowOrganizationId(row); if (t.next ? rowOrg !== null && rowOrg !== organizationId : rowOrg !== organizationId) { @@ -1196,30 +1287,51 @@ export class DelegatedAdminGate { const targets = await this.delegatedTargets(opCtx); for (const t of targets) { const buId = t.next?.business_unit_id ?? null; + const positionSide = t.next && (t.next.position != null || !t.prev) ? 'next' : 'prev'; const positionName = String(t.next?.position ?? t.prev?.position ?? ''); const boundSets = positionName ? await this.setsBoundToPosition(positionName) : []; + // [ruling of record 5985885287, sub-item B] Each value below is named + // only when the caller wrote or reads it ({@link mayName}); the verdict + // is the stored row's either way. + const positionNamed = mayName(t, positionSide, 'position'); const failure = this.firstApprovalFailure(held, (s) => { if (!s.scope.manageAssignments) return 'the scope does not grant manageAssignments'; // New/updated rows must be anchored inside the delegate's subtree. if (t.next) { if (!buId) return 'the assignment has no business_unit_id anchor — delegated assignments must target your subtree (ADR-0090 Addendum)'; - if (!s.subtree.has(String(buId))) return outsideScope(`business unit '${buId}' is outside the delegated subtree`); + if (!s.subtree.has(String(buId))) { + return outsideScope(mayName(t, 'next', 'business_unit_id') + ? `business unit '${buId}' is outside the delegated subtree` + : "the assignment's business unit is outside the delegated subtree"); + } } // Pre-image (update/delete) must also lie inside the subtree — a // delegate can neither capture nor evict assignments beyond it. if (t.prev) { const prevBu = t.prev.business_unit_id ?? null; if (!prevBu) return 'the existing assignment is unanchored (no business_unit_id) — only a tenant admin may modify it'; - if (!s.subtree.has(String(prevBu))) return outsideScope(`the existing assignment's business unit '${prevBu}' is outside the delegated subtree`); + if (!s.subtree.has(String(prevBu))) { + return outsideScope(mayName(t, 'prev', 'business_unit_id') + ? `the existing assignment's business unit '${prevBu}' is outside the delegated subtree` + : "the existing assignment's business unit is outside the delegated subtree"); + } } // Every set the position distributes must be allowlisted - // ({@link setsBoundToPosition}). + // ({@link setsBoundToPosition}). A set is named only beside the + // position it is read from. for (const bound of boundSets) { if (!s.scope.assignablePermissionSets.includes(bound.name)) { - return outsideScope(`position '${positionName}' distributes permission set '${bound.name}', which is not in the scope's allowlist`); + return outsideScope(positionNamed + ? `position '${positionName}' distributes permission set '${bound.name}', which is not in the scope's allowlist` + : "the assignment's position distributes a permission set which is not in the scope's allowlist"); } - const contained = this.assertScopeGrantContainment(bound, held, /*dryRun*/ true); + const contained = this.assertScopeGrantContainment( + bound, + held, + /*dryRun*/ true, + positionNamed ? undefined : "a permission set the assignment's position distributes", + ); if (contained) return contained; } return null; @@ -1228,7 +1340,7 @@ export class DelegatedAdminGate { throw refusalFor( failure, `delegated '${opCtx.operation}' on sys_user_position rejected`, - { operation: opCtx.operation, object: opCtx.object, position: positionName }, + { operation: opCtx.operation, object: opCtx.object, ...(positionNamed ? { position: positionName } : {}) }, ); } } @@ -1239,24 +1351,36 @@ export class DelegatedAdminGate { private async assertDirectGrantWrite(opCtx: any, held: HeldScope[]): Promise { const targets = await this.delegatedTargets(opCtx); for (const t of targets) { + const side = t.next ? 'next' : 'prev'; const row = t.next ?? t.prev ?? {}; - const { setRow, setName, unresolved } = await this.directGrantSet(t, row); + const { setRow, setName, unresolved, setNamed } = await this.directGrantSet(t, row); const targetUserId = row.user_id ? String(row.user_id) : null; const userBUs = targetUserId ? await this.businessUnitsOfUser(targetUserId) : new Set(); + // [ruling of record 5985885287, sub-item B] Named only when the caller + // wrote or reads it ({@link mayName}); the verdict is the stored row's. + const userNamed = mayName(t, side, 'user_id'); const failure = this.firstApprovalFailure(held, (s) => { if (!s.scope.manageAssignments) return 'the scope does not grant manageAssignments'; if (unresolved) return unresolved; if (!s.scope.assignablePermissionSets.includes(setName)) { - return outsideScope(`permission set '${setName}' is not in the scope's allowlist`); + return outsideScope(setNamed + ? `permission set '${setName}' is not in the scope's allowlist` + : "the grant's permission set is not in the scope's allowlist"); } if (!targetUserId) return 'the grant names no target user'; - if (userBUs.size === 0) return `target user '${targetUserId}' has no business-unit membership — only a tenant admin may grant outside the tree`; + if (userBUs.size === 0) { + return `${userNamed ? `target user '${targetUserId}'` : "the grant's target user"} has no business-unit membership — only a tenant admin may grant outside the tree`; + } let inSubtree = false; for (const bu of userBUs) if (s.subtree.has(bu)) { inSubtree = true; break; } - if (!inSubtree) return outsideScope(`target user '${targetUserId}' is outside the delegated subtree`); + if (!inSubtree) { + return outsideScope(userNamed + ? `target user '${targetUserId}' is outside the delegated subtree` + : "the grant's target user is outside the delegated subtree"); + } if (setRow) { - const contained = this.assertScopeGrantContainment(setRow, held, true); + const contained = this.assertScopeGrantContainment(setRow, held, true, setNamed ? undefined : "the grant's permission set"); if (contained) return contained; } return null; @@ -1265,7 +1389,7 @@ export class DelegatedAdminGate { throw refusalFor( failure, `delegated '${opCtx.operation}' on sys_user_permission_set rejected`, - { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, + { operation: opCtx.operation, object: opCtx.object, ...(setNamed ? { permissionSet: setName } : {}) }, ); } } @@ -1295,8 +1419,11 @@ export class DelegatedAdminGate { * If `setRowOrDef` itself carries an adminScope, verify strict containment * against the actor's held scopes. Returns a failure description or null. * (`dryRun` callers use the string as a per-scope rejection reason.) + * `unnamed`, when given, is how the description refers to the set instead of + * by name — for a set read from a value the caller may not be told + * ({@link mayName}). */ - private assertScopeGrantContainment(setRowOrDef: any, held: HeldScope[], _dryRun: boolean): string | null { + private assertScopeGrantContainment(setRowOrDef: any, held: HeldScope[], _dryRun: boolean, unnamed?: string): string | null { const carried: AdminScope | undefined = parseMaybeJson(setRowOrDef.admin_scope ?? setRowOrDef.adminScope) ?? undefined; if (!carried) return null; @@ -1306,7 +1433,7 @@ export class DelegatedAdminGate { for (const s of held) { if (this.definitionContainsStrictly(s.scope, carried)) return null; } - return `granting set '${setRowOrDef.name ?? '?'}' would hand out an adminScope not strictly contained by yours`; + return `granting ${unnamed ?? `set '${setRowOrDef.name ?? '?'}'`} would hand out an adminScope not strictly contained by yours`; } /** Definition-level strict containment (no tree resolution): same-root-or- @@ -1504,28 +1631,42 @@ export class DelegatedAdminGate { * (`readDoorAnswerForGateRefusal`), for every id alike. A store fault, in * either read, propagates as raised: an outage is neither an answer nor a * refusal. + * + * No row is {@link UnreadableAddressedRowRefusal}, which the middleware + * answers for the by-id write the caller addressed with the read door's + * not-found. Each target also carries what the caller may be told of it + * ({@link DelegatedTarget}): the columns its payload wrote, and the columns + * its own read returned at the stored value — none, when that read was + * refused. */ - private async delegatedTargets(opCtx: any): Promise> { + private async delegatedTargets(opCtx: any): Promise { const op = opCtx.operation; const payload = rowsOf(opCtx); - if (op === 'insert') return payload.map((r) => ({ next: r, prev: null })); + if (op === 'insert') return payload.map((r) => ({ next: r, prev: null, wrote: null, reads: new Set() })); const id = extractSingleId(opCtx) as string | number; - const prev = (await this.hiddenFromCaller(opCtx, id)) ? null : await this.storedRow(opCtx.object, id); + const read = await this.callerReadUnlessRefused(opCtx, id); + const prev = !read.refused && read.row === null ? null : await this.storedRow(opCtx.object, id); if (!prev) { - throw new PermissionDeniedError( + throw new UnreadableAddressedRowRefusal( `[Security] Access denied: delegated '${op}' on ${opCtx.object} rejected — the assignment row it addresses could not be read.`, { operation: op, object: opCtx.object }, ); } + const reads = columnsReadAsStored(read.refused ? null : read.row, prev); if (op === 'update') { const next = payload.length > 0 ? payload[0] : null; // Merge unchanged pre-image values so boundary checks see the full row // (an update payload may carry only the changed columns). - return [{ next: next ? { ...prev, ...next } : prev, prev }]; + return [{ + next: next ? { ...prev, ...next } : prev, + prev, + wrote: new Set(next && typeof next === 'object' ? Object.keys(next) : []), + reads, + }]; } // delete / transfer / restore / purge — judged on the pre-image alone. - return [{ next: null, prev }]; + return [{ next: null, prev, wrote: new Set(), reads }]; } /** @@ -1534,11 +1675,23 @@ export class DelegatedAdminGate { * fault propagates. */ private async hiddenFromCaller(opCtx: any, id: string | number): Promise { + const read = await this.callerReadUnlessRefused(opCtx, id); + return !read.refused && read.row === null; + } + + /** + * The caller's own read of row `id` ({@link callerRead}), or `refused` when + * the read door refuses it (a declared 4xx). A store fault propagates. + */ + private async callerReadUnlessRefused( + opCtx: any, + id: string | number, + ): Promise<{ refused: true } | { refused: false; row: any | null }> { try { - return (await this.callerRead(opCtx, id)) === null; + return { refused: false, row: await this.callerRead(opCtx, id) }; } catch (e) { const status = declaredHttpStatus(e); - if (status !== undefined && status < 500) return false; + if (status !== undefined && status < 500) return { refused: true }; throw e; } } @@ -1604,22 +1757,28 @@ export class DelegatedAdminGate { * still judged against the allowlist (the write is then refused 400 by the * grant's own name refusal, `grant-permission-set-name.ts`); no * `sys_permission_set` row and no `permission_set_id` is read. + * + * `setNamed` says whether a refusal may name the set ({@link mayName}): the + * name the write points the grant at always (the caller's own); the grant's + * own name only when the caller's read returned it. */ private async directGrantSet( - t: { next: any | null; prev: any | null }, + t: DelegatedTarget, row: any, - ): Promise<{ setRow: any | null; setName: string; unresolved?: string }> { + ): Promise<{ setRow: any | null; setName: string; setNamed: boolean; unresolved?: string }> { const repointed = !!t.next && (!t.prev || grantSetNameOf(t.next) !== grantSetNameOf(t.prev)); if (repointed) { const nextName = grantSetNameOf(t.next) ?? ''; const setRow = nextName ? await this.catalogSet(nextName) : null; - return { setRow, setName: nextName }; + return { setRow, setName: nextName, setNamed: true }; } + const setNamed = mayName(t, t.next ? 'next' : 'prev', 'permission_set'); const name = grantSetNameOf(row); if (!name) { return { setRow: null, setName: '', + setNamed, unresolved: 'the grant names no permission set (it is not yet named, or could not be) — only a tenant admin may change it', }; } @@ -1628,10 +1787,11 @@ export class DelegatedAdminGate { return { setRow: null, setName: name, - unresolved: `permission set '${name}' is not in the security catalog, so this grant confers nothing — only a tenant admin may change it`, + setNamed, + unresolved: `${setNamed ? `permission set '${name}'` : "the grant's permission set"} is not in the security catalog, so this grant confers nothing — only a tenant admin may change it`, }; } - return { setRow, setName: name }; + return { setRow, setName: name, setNamed }; } /** 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 5b65837cde2..b06d3ca05d1 100644 --- a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts +++ b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts @@ -9,7 +9,8 @@ * 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 * (whose refusal of a by-id write answers a caller the read door refuses with - * that read refusal, `readDoorAnswerForGateRefusal`), + * that read refusal, and a row the caller's read does not return with the read + * door's not-found, `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 @@ -103,6 +104,7 @@ 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'; +import { isUnreadableAddressedRowRefusal } from './delegated-admin-gate.js'; import { isRowWriteProbe, logGateRefusal, type AuthzMiddlewareFn } from './record-row-write-probe.js'; import { intersectDelegatedScope, @@ -234,7 +236,8 @@ function userFacingDenialMessage( /** * [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. + * refuses — that door's refusal — and a caller whose read returns no row for + * a write the gate refused for that reason: that door's not-found. * * 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 @@ -251,8 +254,15 @@ function userFacingDenialMessage( * - 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 answers NO ROW, and the gate refused because its own read of + * the row returned none (`isUnreadableAddressedRowRefusal`: the + * organization administrator's branch and the delegated branch): the read + * door's not-found, `recordNotFoundError` for this object and id — the + * answer step 2.7 gives every other object, so a row the caller cannot read + * and an id naming none answer alike here too, and as they do everywhere; + * - the read ANSWERS otherwise (a row; or none, under any other refusal of + * the gate): 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. * @@ -273,13 +283,17 @@ async function readDoorAnswerForGateRefusal( const referentialFieldClear = opCtx.operation === 'update' && opCtx.context?.__referentialFieldClear === true; const addressedId = this.addressedByIdWriteId(opCtx, nestedInOperation, referentialFieldClear); if (addressedId === undefined) return refusal; + let row: Record | null; try { - await this.getCallerPreImage(opCtx, addressedId); + row = await this.getCallerPreImage(opCtx, addressedId); } catch (readRefusal) { const readStatus = declaredHttpStatus(readRefusal); if (readStatus !== undefined && readStatus < 500) return readRefusal; throw readRefusal; } + if (row == null && isUnreadableAddressedRowRefusal(refusal)) { + return recordNotFoundError(opCtx.object, addressedId as string | number); + } return refusal; } @@ -543,7 +557,8 @@ export function registerAuthzMiddleware( // // 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}. + // is answered that read refusal instead, and a row its read does not + // return the read door's not-found — {@link readDoorAnswerForGateRefusal}. if (this.delegatedAdminGate) { try { await this.delegatedAdminGate.assert(opCtx); From 49b6cfa956e4266ac7cbe9c3551555df5a62e025 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:02:01 +0000 Subject: [PATCH 2/6] test(plugin-security): pin the not-found answer and the value-free refusal wording for assignment writes Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- ...nment-write-unaddressable-row-not-found.md | 17 ++ ...-admin-assignment-boundary.posture.test.ts | 188 ++++++++++++++---- ...nment-write-unreadable-row.dogfood.test.ts | 31 +-- ...ent-removal-unreadable-row.dogfood.test.ts | 30 +-- 4 files changed, 193 insertions(+), 73 deletions(-) create mode 100644 .changeset/assignment-write-unaddressable-row-not-found.md diff --git a/.changeset/assignment-write-unaddressable-row-not-found.md b/.changeset/assignment-write-unaddressable-row-not-found.md new file mode 100644 index 00000000000..a088e923502 --- /dev/null +++ b/.changeset/assignment-write-unaddressable-row-not-found.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): an assignment write the administrator cannot address answers not-found, as a read does; a refusal names only values the caller can read + +Clause-②: no +Metadata-protocol: none + +An organization administrator or a delegated administrator (a caller holding an `adminScope`, an organization administrator who holds one included) updates or removes an assignment row (`sys_user_position` or `sys_user_permission_set`) by id. + +- **A row the caller cannot read, and an id that names no row,** now answer `404 RECORD_NOT_FOUND`: the read door's answer for that object and id, which is what a by-id write of any other object already answers. Both used to answer `403 PERMISSION_DENIED` ("the assignment row … could not be read"). An organization administrator's edit in place is asked the same question first, so an edit of a row it reads is still refused as an edit. +- **A caller the read door refuses outright** (no read grant on the table) still gets that read refusal. +- **A refusal names a value only when the caller can read it.** A delegated administrator's `DELEGATION_SCOPE_VIOLATION` names the row's position, business unit, holder or permission set only when the caller's own read returns that column at its stored value, or when the caller wrote the value itself. Otherwise the sentence is the same without the value (for example "the assignment's position distributes a permission set which is not in the scope's allowlist"), and `details` leaves the value out. The verdict does not change: it is still taken on the stored row. +- **Unchanged.** A row the caller reads keeps its verdict and its guidance. Inserts and tenant-level administrators are judged as before, and every write that was admitted is still admitted. + +For clients: a by-id update or delete of an assignment row that is gone now answers `404`, as it does on every other object. diff --git a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts index 30779aa2384..0a765c4edaf 100644 --- a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts +++ b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts @@ -17,17 +17,20 @@ * the failed condition. Every admission reads the stored row back. * * At the foot: under the walled postures, an organization administrator's - * update and delete of a row it cannot read answer exactly what an id naming - * no row answers (ruling of record 5985885287) — the whole refusal, sentence - * and details included; a row it reads keeps the boundary guidance, and a - * tenant-level administrator is answered by the engine as before. The same - * rule for the delegated branch follows: a delegate holding an `adminScope`, - * an organization administrator who holds one too, and a delegate whose read - * of the two tables is refused outright; rows it reads keep their verdict and - * their guidance, in every posture. + * update and delete of a row it cannot read, and of an id naming no row, + * answer the read door's not-found for that id (ruling of record 5985885287) + * — whole, and equal to what step 2.7 answers the tenant-level administrator + * for the same id; a row it reads keeps the boundary guidance. The same rule + * for the delegated branch follows: a delegate holding an `adminScope` and an + * organization administrator who holds one too are answered the not-found, + * and a delegate whose read of the two tables is refused outright is answered + * that read refusal; rows it reads keep their verdict and their guidance, in + * every posture. Last, a refusal names a stored value only when the caller + * reads it or wrote it, in every posture. */ import { describe, it, expect, afterEach, vi } from 'vitest'; +import { recordNotFoundError } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveThrownHttpError } from '@objectstack/types'; @@ -58,21 +61,27 @@ const POSITIONS = [ * The delegated branch's sets (the describe at the foot): one `adminScope` over * organization A's `a_ops` unit and its subtree, handing out `qa_in_set` only, * held with both tables' CRUD; with CRUD but no read; and with a field rule - * that hides `position` from the holder's reads. + * that hides one column a refusal could name from the holder's reads. */ const A_OPS_SCOPE = { businessUnit: 'a_ops', includeSubtree: true, manageAssignments: true, assignablePermissionSets: ['qa_in_set'] }; const READ_WRITE = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; const WRITE_ONLY = { allowCreate: true, allowEdit: true, allowDelete: true }; +const HIDDEN = { readable: false, editable: false }; +/** A delegate holding the `a_ops` scope and both tables' CRUD, with a field rule hiding `fields` from its reads. */ +const delegateHiding = (name: string, fields: Record