Skip to content

Commit d0da880

Browse files
fix(plugin-security): an organization administrator's assignment removal is judged on the stored row, and its refusal names only values the caller can read (#30248)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:组织管理员按 id 删除授权行(`sys_user_position`、`sys_user_permission_set`)时,只要它自己读得到这一行,委派管理闸门(ADR-0090 D12)的组织分支就按存储行判定,和委派分支一样;拒绝提示只写出调用者自己读得到的值。 - 为什么改:落实维护者裁决(ruling of record 6649064874)「ADR-0090 D12 授权闸门按存储行判定,拒绝提示只写出调用者读得到的值」,该裁决覆盖两个分支;#30165 的契约复核把组织分支与之不一致记为 should-fix。 - 风险与回滚:这是放宽:调用者的字段规则或遮罩规则作用在 `position` / `permission_set` 上时,组织边界内的删除现在放行(18.2.0 即按存储行判定);边界外照旧拒绝;读不到的行与不存在的 id 仍回答 404;修改、插入、租户级管理员、委派分支都不变。回滚即撤销本 PR。 - 先看:`packages/plugins/plugin-security/src/delegated-admin-gate.ts` 的 `organizationTargets`(先以调用者读取判断有没有这一行,读到后取存储行)与 `organizationAdminFailure` 里三处 `mayName` 措辞分支。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/org-admin-assignment-boundary.posture.test.ts`,预期 85/85 通过。 - 需要你做的:确认本 PR 对未发布 changeset `.changeset/org-admin-removal-caller-read.md` 一句话的改写(见 Acceptance notes 第一条);在你确认之前 `Check Changeset` 按设计保持红。 ## What changes - **The organization branch judges a removal on the stored row.** An organization administrator (`organization_admin` / `organization_admin_no_bypass`) removes an assignment row by id. Whether there is a row is still asked of the caller's own read (`callerRead`, the read door's answer to this caller). Once that read returns the row, `organizationTargets` takes the stored row (`storedRow`) and the organization, position and permission-set checks run on it, as the delegated branch's `delegatedTargets` does. This follows ruling of record 6649064874 ("the ADR-0090 D12 assignment gate judges on the stored row, and its refusal names only what the caller can read"), which covers both branches. No second reader: the branch reuses `callerRead`, `storedRow`, `columnsReadAsStored` and `mayName`, and its target type is now `DelegatedTarget`. No stored read is made for a row the caller's read does not return. - **One ruling record for the rule, across the package.** Every citation of the stored-row verdict and of the rule that a refusal names only what the caller can read, in this branch's code and in the code it builds on, now names ruling of record 6649064874; the citations of 5985885287 for the read door's not-found answer are kept. - **A refusal names a value only when the caller can read it.** The row's organization, its position (and the sets that position carries, which are named only beside it) and its permission set are named only when the caller's read returned that column at its stored value (`mayName`). Otherwise the sentence is "the row does not belong to the organization you administer", "the assignment's position is not inside the organization boundary" or "the grant's permission set is not inside the organization boundary", and `details` leaves the value out. A column the caller does read is still named, so the guidance stays specific where it can be. - **Unchanged.** A row the caller cannot read and an id naming no row keep the read door's `404 RECORD_NOT_FOUND`. A read refusal or a store fault propagates as raised. Inserts, tenant-level administrators and the delegated branch are as before. The update path is untouched: an edit in place is refused before any row is looked up, as #30200 set it, and nothing here pins it. - **Clause-② declaration, measured.** This admits removals the published 18.3.0 refuses, so it widens; that is declared here and in the changeset. It is `Clause-②: no` because it is a runtime permission behaviour change, which `execution-duties.md` places outside Clause ② (its negative boundary: runtime permission and security behaviour is the human-floor security/permission class), and no published contract surface moves: no spec schema, no export (the package's public API is unchanged), no error code. For history: 18.2.0's organization branch judged the stored row (a system read), and 18.3.0's judged the caller's read of it; this keeps 18.3.0's not-found for a row the caller cannot read and takes the verdict from the stored row again. - **Changeset**: `@objectstack/plugin-security` patch, plus a one-sentence correction of a pending note (Acceptance notes, first item). ## Tests - `org-admin-assignment-boundary.posture.test.ts` (real `ObjectQL` engine, SQL driver and `SecurityPlugin`), new describe "an organization administrator's removal is judged on the stored row, and its refusal names only values the caller can read", 11 tests: - under `group` and `isolated`, for a position or permission set hidden by a field rule and for one partially masked: the caller reads the row without the stored value (precondition, off the read door); the removal of an in-boundary row is admitted and lands; the removal of an out-of-boundary row is refused `403 PERMISSION_DENIED` by the boundary, and neither the stored values (the position, the sets behind it, the permission set) nor the masked value appear anywhere in the serialized answer; - under `group` and `isolated`, for the row's organization hidden by a field rule and partially masked: the plugin's own D12 gate, asked as the write door asks it, admits the in-boundary removal on the stored organization, and refuses the out-of-boundary one naming the values the caller reads (the set) and not the organization, through the gate and through the door; - control, `group` and `isolated`: callers whose field rules hide a judged column still get the read door's not-found, whole, when they remove a row they cannot read or an id naming no row; - control, `single`: a caller whose field rules hide the organization removes its own organization's in-boundary row end to end, and is refused another organization's row without that organization being named. - **Re-pinned #30165's organization-branch pin** in "an administrator's refused assignment write names a value only when the caller reads it, or wrote it" (all three postures). That pin asserted only that the refusal named no value the caller does not read. It now also asserts the boundary's refusal on the stored row ("is not inside the organization boundary") and adds the same caller's in-boundary removal, admitted. - `delegated-admin-gate.test.ts`, new top-level describe on the existing organization harness, 20 tests: for both administrator sets, each of `position`, `permission_set` and `organization_id` (both tables) served hidden and masked, in-boundary removals resolve and out-of-boundary ones are refused with the rule's sentence and without the stored or masked value; a column the caller reads keeps its value in the guidance; a row the caller's read does not return and an id naming no row are the unreadable refusal, with no stored read of the addressed row. - Test names state the rule. The posture rig grows from 74 to 85 tests; its new describe adds about 1.4 s of test time on a loaded shared box, inside the file's declared 20 s budget. ## Acceptance notes - **Correcting a pending release note: please confirm.** `.changeset/org-admin-removal-caller-read.md` has not shipped as a note (the plugin's `CHANGELOG.md` at 18.3.0 holds none of it). Its sentence "The delegated-administration gate (ADR-0090 D12) now judges that row as the administrator itself reads it, in its own context." is false after this change, so it now reads "now asks the administrator's own read, in its own context, whether that row is there to remove." Two of its bullets are corrected too: the not-found bullet now says "the removal of a row the administrator cannot read" (an edit in place is refused before any row is looked up, so the 404 is a removal's answer), and "A row the administrator can read keeps the answer it had, including the guidance…" now reads "A row the administrator can read is judged on the stored row. A refusal names the organization boundary, and names the row's organization, position or permission set only when the administrator's own read returns that value as stored." Its title, its first paragraph and its "Unchanged" bullet are true of the tree as written. `check-empty-changeset` names the file and stays red by design until a maintainer confirms; restoring the sentence from the base would publish a false one. - **The row's organization column.** This gate judges it on the stored row too. The rig pins that at the gate under `group` and `isolated`, and end to end under `single`. Under the walled postures a write also answers the write door's own row-level check, which this PR does not change; how that check treats a caller who does not read the organization column is a separate change the seat has ordered. - **#30200 is merged in.** It reorders the organization branch's update refusal and edits the same docblock as this PR; the merge conflicted in that docblock only, and the resolution keeps both texts (its update-order sentences replace this branch's earlier sentence about edits). This PR's new pins exercise removals only, and `update-by-id-refusal-order.dogfood.test.ts` passes on this head (Verification). ## Verification Readings at `d33de915d2`, this body's head: the plugin typecheck, the whole suite, the two edited files, the three HTTP dogfood files and the derived gates. The ablations ran at `5fd3195845`; since then the gate file changed only by the merge of #30200 (its docblock and its update ordering). CI's families are this PR's own check-runs; `test:repo` and the whole-tree lint are CI's. <details><summary>plugin-security: typecheck, the whole suite, the two edited files</summary> ```text pnpm --filter @objectstack/plugin-security typecheck exit 0 (check:test-typecheck OK, 0 errors) vitest run, whole package, under the shared verify lock Test Files 242 passed (242) · Tests 5318 passed | 45 skipped (5363) vitest run src/org-admin-assignment-boundary.posture.test.ts Tests 85 passed (85) vitest run src/delegated-admin-gate.test.ts Tests 157 passed (157) ``` </details> <details><summary>HTTP dogfood: the update-by-id refusal order and the two unreadable-row files</summary> ```text closure build (pnpm --filter '@objectstack/dogfood^...' build) exit 0 ablation-dist-preflight, this change's wording present in plugin-security dist/index.js and dist/index.mjs update-by-id-refusal-order, org-admin-assignment-removal-unreadable-row, delegated-admin-assignment-write-unreadable-row Test Files 3 passed (3) · Tests 25 passed (25) ``` </details> <details><summary>One-shot ablations of the two edited files (their subject resolves from this package's own source)</summary> ```text A1 organizationTargets judges the caller's read again (the stored read replaced by the caller's row) -> Tests 30 failed | 212 passed: every new test except the four not-found controls, and the re-pinned field-rule test in all three postures A2 the position refusal names the position whatever the caller reads -> Tests 11 failed | 231 passed: the position cases of both new describes (rig: hidden and masked, group and isolated; unit: both sets, hidden and masked), and the re-pinned field-rule test in all three postures restore, both: blob == HEAD (fbfc61a) and `git diff HEAD` empty (scripts/ablation-replace.mjs) ``` </details> <details><summary>Derived gate families (dispatch-gates --commands: 60)</summary> ```text 59 of the 60 commands :: exit 0, each run once at this head with its exit code captured before any pipe node scripts/check-empty-changeset.mjs --base origin/main :: exit 1 the deliberate correction of one pending changeset (Acceptance notes, first item) dispatch-gates --ran: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN ``` </details> <details><summary>Contract-review findings commits 0cc6f17 and 0b0d0e2 (comments, describe names and the corrected pending note)</summary> ```text pnpm --filter @objectstack/plugin-security typecheck exit 0 (check:test-typecheck OK, 0 errors) vitest run src/org-admin-assignment-boundary.posture.test.ts Tests 85 passed (85) vitest run src/delegated-admin-gate.test.ts Tests 157 passed (157) node scripts/check-empty-changeset.mjs --base origin/main exit 1 (only .changeset/org-admin-removal-caller-read.md, the declared correction) check-adr-0087-registration, check-changeset-no-major, check:issue-citations, check:doc-authoring, check:nul-bytes, check:closed-ceremony-text exit 0 each ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07685ae commit d0da880

5 files changed

Lines changed: 361 additions & 52 deletions

File tree

‎.changeset/org-admin-removal-caller-read.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ fix(plugin-security): an organization administrator's refused assignment write a
77
Clause-②: no
88
Metadata-protocol: none
99

10-
An organization administrator (`organization_admin` or `organization_admin_no_bypass`) removes an assignment row (`sys_user_position` or `sys_user_permission_set`) by id. The delegated-administration gate (ADR-0090 D12) now judges that row as the administrator itself reads it, in its own context.
10+
An organization administrator (`organization_admin` or `organization_admin_no_bypass`) removes an assignment row (`sys_user_position` or `sys_user_permission_set`) by id. The delegated-administration gate (ADR-0090 D12) now asks the administrator's own read, in its own context, whether that row is there to remove.
1111

12-
- **A row the administrator cannot read** is answered exactly as an id that names no row is: the read door's `404 RECORD_NOT_FOUND` for that object and id.
13-
- **A row the administrator can read** keeps the answer it had, including the guidance that names the organization boundary.
12+
- **The removal of a row the administrator cannot read** is answered exactly as the removal of an id that names no row is: the read door's `404 RECORD_NOT_FOUND` for that object and id.
13+
- **A row the administrator can read** is judged on the stored row. A refusal names the organization boundary, and names the row's organization, position or permission set only when the administrator's own read returns that value as stored.
1414
- **Unchanged.** An edit in place of a row it reads is refused as before, every removal that was admitted is still admitted, and a tenant-level administrator is judged as before.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): an organization administrator's assignment removal is judged on the stored row, and its refusal names only values the caller can read
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An organization administrator (`organization_admin` or `organization_admin_no_bypass`) removes an assignment row (`sys_user_position` or `sys_user_permission_set`) by id. Once its own read returns the row, the delegated-administration gate (ADR-0090 D12) judges the removal on the stored row, as it judges a delegated administrator's write.
11+
12+
- **The verdict is the stored row's.** A field rule or a masking rule on the row's `position` or `permission_set` does not change it: the removal of a row inside the organization boundary is admitted, and the removal of a row outside it is refused.
13+
- **A refusal names a value only when the caller can read it.** The row's organization, its position and the sets that position carries, or its permission set are named only when the caller's own read returned that column at its stored value. Otherwise the sentence leaves the value out, for example "the assignment's position is not inside the organization boundary", and `details` leaves it out too.
14+
- **Unchanged.** A row the caller cannot read, and an id that names no row, still answer the read door's `404 RECORD_NOT_FOUND`. An edit in place is refused as before. Inserts, tenant-level administrators and delegated administrators are judged as before.

‎packages/plugins/plugin-security/src/delegated-admin-gate.test.ts‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { describe, it, expect, beforeEach } from 'vitest';
55
import { mapDataError } from '@objectstack/types';
6-
import { DelegatedAdminGate } from './delegated-admin-gate';
6+
import { DelegatedAdminGate, isUnreadableAddressedRowRefusal } from './delegated-admin-gate';
77
import { isPermissionDeniedError } from './errors.js';
88
import { bindCatalogFromTables, bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js';
99

@@ -1389,6 +1389,98 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am
13891389
});
13901390
});
13911391

1392+
/**
1393+
* The organization harness's caller reads served through a field rule on
1394+
* `column`: absent from the row (`hidden`) or replaced by a partial mask
1395+
* (`masked`), as the read door serves them. System reads see the stored row.
1396+
* Returns the objects the gate read by the system, in order.
1397+
*/
1398+
function serveCallerReadsThrough(o: ReturnType<typeof makeOrganizationHarness>, column: string, how: 'hidden' | 'masked'): string[] {
1399+
const storedFindOne = o.ql.findOne.bind(o.ql);
1400+
const systemReads: string[] = [];
1401+
o.ql.findOne = async (object: string, opts: any) => {
1402+
const row = await storedFindOne(object, opts);
1403+
if (opts?.context?.isSystem) {
1404+
systemReads.push(object);
1405+
return row;
1406+
}
1407+
if (!row || !Object.prototype.hasOwnProperty.call(row, column)) return row;
1408+
const seen = { ...row };
1409+
if (how === 'hidden') delete seen[column];
1410+
else seen[column] = `${String(row[column]).slice(0, 2)}****`;
1411+
return seen;
1412+
};
1413+
return systemReads;
1414+
}
1415+
1416+
describe("[ADR-0090 D12; ruling of record 6649064874] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller reads", () => {
1417+
let o: ReturnType<typeof makeOrganizationHarness>;
1418+
beforeEach(() => { o = makeOrganizationHarness(); });
1419+
1420+
const remove = (principal: string, object: string, id: string) =>
1421+
o.gate.assert({ object, operation: 'delete', options: { where: { id } }, context: o.ctxOf(principal) });
1422+
1423+
/** One refusal, serialized whole: its sentence and every detail it carries. */
1424+
const whole = (err: any) => JSON.stringify({ message: err.message, details: err.details });
1425+
1426+
/**
1427+
* Per column a removal is judged on: the in-boundary rows the stored-row
1428+
* verdict admits, the rows it refuses, the stored values each refusal
1429+
* leaves out, and the sentence it says instead.
1430+
*/
1431+
const CASES = [
1432+
{ column: 'position', object: 'sys_user_position', admitted: ['up_a'],
1433+
refused: [{ id: 'up_a_mixed', unnamed: ['mixed_pos', 'wild_set', 'in_set'], says: /the assignment's position is not inside the organization boundary/ }] },
1434+
{ column: 'permission_set', object: 'sys_user_permission_set', admitted: ['ups_a'],
1435+
refused: [{ id: 'ups_a_wild', unnamed: ['wild_set'], says: /the grant's permission set is not inside the organization boundary/ }] },
1436+
{ column: 'organization_id', object: 'sys_user_position', admitted: ['up_a'],
1437+
refused: [
1438+
{ id: 'up_b', unnamed: ['org_b'], says: /the row does not belong to the organization you administer/ },
1439+
{ id: 'up_orgless', unnamed: ['carries no organization'], says: /the row does not belong to the organization you administer/ },
1440+
] },
1441+
{ column: 'organization_id', object: 'sys_user_permission_set', admitted: ['ups_a'], refused: [] },
1442+
] as const;
1443+
1444+
for (const principal of ['org_admin', 'org_admin_nb']) {
1445+
for (const how of ['hidden', 'masked'] as const) {
1446+
for (const c of CASES) {
1447+
it(`${principal}: with ${c.object}.${c.column} ${how} from its reads, an in-boundary row is removed, and a row outside is refused without the stored value`, async () => {
1448+
serveCallerReadsThrough(o, c.column, how);
1449+
for (const id of c.admitted) {
1450+
await expect(remove(principal, c.object, id), id).resolves.toBeUndefined();
1451+
}
1452+
for (const r of c.refused) {
1453+
const err = await deniedOf(() => remove(principal, c.object, r.id));
1454+
expect(err.message, r.id).toMatch(/organization boundary/);
1455+
expect(err.message, r.id).toMatch(r.says);
1456+
for (const value of r.unnamed) expect(whole(err), `${r.id}: '${value}'`).not.toContain(value);
1457+
expect(whole(err), `${r.id}: not the masked value either`).not.toContain('****');
1458+
}
1459+
});
1460+
}
1461+
}
1462+
1463+
it(`${principal}: a column it does read keeps the stored value in the guidance — the position and its set, beside an organization it does not read`, async () => {
1464+
serveCallerReadsThrough(o, 'organization_id', 'hidden');
1465+
const err = await deniedOf(() => remove(principal, 'sys_user_position', 'up_a_mixed'));
1466+
expect(err.message).toMatch(/position 'mixed_pos' carries permission set 'wild_set'.*condition \(a\)/);
1467+
expect(err.details).toMatchObject({ position: 'mixed_pos', permissionSet: 'wild_set' });
1468+
});
1469+
1470+
it(`${principal}: a row its own read does not return, and an id naming no row, are refused as no row — with no read of the stored row`, async () => {
1471+
const systemReads = serveCallerReadsThrough(o, 'position', 'hidden');
1472+
const servedFindOne = o.ql.findOne;
1473+
o.ql.findOne = async (object: string, opts: any) =>
1474+
(!opts?.context?.isSystem && opts?.where?.id === 'up_a') ? null : servedFindOne(object, opts);
1475+
for (const id of ['up_a', 'zz_no_such_row']) {
1476+
const err = await deniedOf(() => remove(principal, 'sys_user_position', id));
1477+
expect(isUnreadableAddressedRowRefusal(err), id).toBe(true);
1478+
}
1479+
expect(systemReads.filter((object) => object === 'sys_user_position'), 'no system read of the addressed row').toEqual([]);
1480+
});
1481+
}
1482+
});
1483+
13921484
// ---------------------------------------------------------------------------
13931485
// [ADR-0090 D12, amended 2026-10-17; #24757] The delegated walk judges the
13941486
// listed sets, never a set that only shares the position's name

‎packages/plugins/plugin-security/src/delegated-admin-gate.ts‎

Lines changed: 65 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import {
7474
rowOrganizationId,
7575
seedCtx as organizationScopedCtx,
7676
} from './organization-scope.js';
77-
import { grantSetNameOf } from './grant-permission-set-name.js';
77+
import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js';
7878
import { isReservedIdentityName } from './objects/reserved-identity-names.js';
7979

8080
const SYSTEM_CTX = { isSystem: true } as const;
@@ -424,10 +424,11 @@ export function isUnreadableAddressedRowRefusal(refusal: unknown): boolean {
424424
}
425425

426426
/**
427-
* [ADR-0090 D12] A row a delegated write targets, with what the caller may be
428-
* told about it: `prev` the stored row a by-id write addresses, `next` the row
429-
* an insert or an update writes, and the two column sets a refusal's guidance
430-
* reads ({@link mayName}).
427+
* [ADR-0090 D12] A row an administrator's write targets — the delegated walk's
428+
* and the organization branch's alike — with what the caller may be told about
429+
* it: `prev` the stored row a by-id write addresses, `next` the row an insert
430+
* or an update writes, and the two column sets a refusal's guidance reads
431+
* ({@link mayName}).
431432
*/
432433
interface DelegatedTarget {
433434
next: any | null;
@@ -445,7 +446,7 @@ interface DelegatedTarget {
445446
}
446447

447448
/**
448-
* [ruling of record 5985885287, sub-item B] The columns of the stored row
449+
* [ruling of record 6649064874] The columns of the stored row
449450
* `prev` that the caller's own read `seen` returned at their stored value.
450451
*
451452
* The read is the read door's answer to this caller, field rules and masking
@@ -463,7 +464,7 @@ function columnsReadAsStored(seen: any | null, prev: any | null): Set<string> {
463464
}
464465

465466
/**
466-
* [ruling of record 5985885287, sub-item B] May a refusal of `t` name the
467+
* [ruling of record 6649064874] May a refusal of `t` name the
467468
* value of `column` on its `side`?
468469
*
469470
* Only a value the caller already has: one it wrote (an insert's row, a
@@ -847,13 +848,18 @@ export class DelegatedAdminGate {
847848
* ({@link organizationBoundaryFailure}); for a position, every set it carries
848849
* ({@link setsCarriedByPosition}). One set outside puts the row outside.
849850
*
850-
* The row a removal targets is judged as the CALLER reads it
851+
* Whether a removal has a row to judge is asked of the CALLER's own read
851852
* ({@link organizationTargets}), so a row the caller cannot read is answered
852-
* exactly as an id naming no row is (ruling of record 5985885287). An edit
853-
* in place is a verb the right never holds, so it is refused before any row
854-
* is looked up, whatever the id names: a row and no row get the same
855-
* refusal (the update-by-id refusal order). The not-found is the answer
856-
* only for a verb the caller holds, judged on the row.
853+
* exactly as an id naming no row is (ruling of record 5985885287). A row the
854+
* caller reads is judged on the STORED row, as the delegated branch judges
855+
* it (ruling of record 6649064874): the caller's read is served through its
856+
* field rules and masking, and a verdict taken on what a rule hid would be
857+
* taken on less than the row. A refusal names a value only when the caller
858+
* reads it at its stored value or wrote it ({@link mayName}). An edit in
859+
* place is a verb the right never holds, so it is refused before any row is
860+
* looked up, whatever the id names: a row and no row get the same refusal
861+
* (the update-by-id refusal order). The not-found is the answer only for a
862+
* verb the caller holds, judged on the row.
857863
*/
858864
private async organizationAdminFailure(
859865
opCtx: any,
@@ -877,13 +883,20 @@ export class DelegatedAdminGate {
877883
}
878884

879885
for (const t of await this.organizationTargets(opCtx)) {
886+
const side = t.next ? 'next' : 'prev';
880887
const row = t.next ?? t.prev;
881888
// No row answers an id naming none and a row the caller cannot read
882889
// alike: one refusal, and never a fall-through to the engine.
883890
if (!row) return { reason: 'the assignment row to remove could not be read', meta: {}, unreadable: op === 'delete' };
884891

892+
// [ruling of record 6649064874] The verdict is the stored row's; each
893+
// value below is named only when the caller wrote or reads it
894+
// ({@link mayName}).
885895
const rowOrg = rowOrganizationId(row);
886896
if (t.next ? rowOrg !== null && rowOrg !== organizationId : rowOrg !== organizationId) {
897+
if (!mayName(t, side, 'organization_id')) {
898+
return { reason: 'the row does not belong to the organization you administer', meta: {} };
899+
}
887900
return {
888901
reason: rowOrg
889902
? `the row belongs to organization '${rowOrg}', not the one you administer`
@@ -902,13 +915,22 @@ export class DelegatedAdminGate {
902915
}
903916
const positionName = String(row.position ?? '');
904917
const failure = await this.positionBoundaryFailure(positionName);
905-
if (failure) return { reason: failure.reason, meta: { position: positionName, permissionSet: failure.permissionSet } };
918+
if (failure) {
919+
// A set is named only beside the position it is read from.
920+
return mayName(t, side, 'position')
921+
? { reason: failure.reason, meta: { position: positionName, permissionSet: failure.permissionSet } }
922+
: { reason: "the assignment's position is not inside the organization boundary", meta: {} };
923+
}
906924
} else {
907925
const setName = grantSetNameOf(row) ?? '';
908926
const reason = setName
909927
? await this.organizationBoundaryFailure(setName)
910928
: 'the grant names no permission set';
911-
if (reason) return { reason, meta: { permissionSet: setName } };
929+
if (reason) {
930+
return mayName(t, side, GRANT_SET_NAME_FIELD)
931+
? { reason, meta: { permissionSet: setName } }
932+
: { reason: "the grant's permission set is not inside the organization boundary", meta: {} };
933+
}
912934
}
913935
}
914936

@@ -923,21 +945,30 @@ export class DelegatedAdminGate {
923945

924946
/**
925947
* [ADR-0090 D12; ruling of record 5985885287] The rows an organization
926-
* administrator's insert or removal targets: an insert's payload rows, or the
927-
* one row a removal addresses, as the CALLER reads it.
948+
* administrator's insert or removal targets, with what the caller may be
949+
* told of each ({@link DelegatedTarget}): an insert's payload rows, every
950+
* value the caller's own; or the one row a removal addresses.
928951
*
929-
* The pre-image is the caller's own by-id read through the engine — the read
930-
* door's answer to this caller, every read visibility and wall included —
931-
* never a system read: a row the caller cannot read answers what an id naming
932-
* no row answers. A row it reads is judged on what it read, so its guidance
933-
* stays. A read refusal or a store fault propagates as raised: an outage is
934-
* neither an answer nor a refusal, and the by-id write door answers a caller
935-
* the read door refuses with that refusal.
952+
* Whether a removal has a row is asked of the caller's own by-id read
953+
* ({@link callerRead}) — the read door's answer to this caller, every read
954+
* visibility and wall included: a row the caller cannot read answers what an
955+
* id naming no row answers, and no system read is made for it. A row it
956+
* reads is then judged on the stored row ({@link storedRow}), never on the
957+
* caller's read of it, which is served through the caller's field rules and
958+
* masking (ruling of record 6649064874); `reads` holds the columns that read
959+
* returned at their stored value ({@link columnsReadAsStored}). A read
960+
* refusal or a store fault, in either read, propagates as raised: an outage
961+
* is neither an answer nor a refusal, and the by-id write door answers a
962+
* caller the read door refuses with that refusal.
936963
*/
937-
private async organizationTargets(opCtx: any): Promise<Array<{ next: any | null; prev: any | null }>> {
938-
if (opCtx.operation === 'insert') return rowsOf(opCtx).map((r) => ({ next: r, prev: null }));
964+
private async organizationTargets(opCtx: any): Promise<DelegatedTarget[]> {
965+
if (opCtx.operation === 'insert') {
966+
return rowsOf(opCtx).map((r) => ({ next: r, prev: null, wrote: null, reads: new Set<string>() }));
967+
}
939968
const id = extractSingleId(opCtx);
940-
return [{ next: null, prev: id != null ? await this.callerRead(opCtx, id) : null }];
969+
const seen = id != null ? await this.callerRead(opCtx, id) : null;
970+
const prev = seen && id != null ? await this.storedRow(opCtx.object, id) : null;
971+
return [{ next: null, prev, wrote: new Set<string>(), reads: columnsReadAsStored(seen, prev) }];
941972
}
942973

943974
/**
@@ -1284,7 +1315,7 @@ export class DelegatedAdminGate {
12841315
const positionSide = t.next && (t.next.position != null || !t.prev) ? 'next' : 'prev';
12851316
const positionName = String(t.next?.position ?? t.prev?.position ?? '');
12861317
const boundSets = positionName ? await this.setsBoundToPosition(positionName) : [];
1287-
// [ruling of record 5985885287, sub-item B] Each value below is named
1318+
// [ruling of record 6649064874] Each value below is named
12881319
// only when the caller wrote or reads it ({@link mayName}); the verdict
12891320
// is the stored row's either way.
12901321
const positionNamed = mayName(t, positionSide, 'position');
@@ -1350,7 +1381,7 @@ export class DelegatedAdminGate {
13501381
const { setRow, setName, unresolved, setNamed } = await this.directGrantSet(t, row);
13511382
const targetUserId = row.user_id ? String(row.user_id) : null;
13521383
const userBUs = targetUserId ? await this.businessUnitsOfUser(targetUserId) : new Set<string>();
1353-
// [ruling of record 5985885287, sub-item B] Named only when the caller
1384+
// [ruling of record 6649064874] Named only when the caller
13541385
// wrote or reads it ({@link mayName}); the verdict is the stored row's.
13551386
const userNamed = mayName(t, side, 'user_id');
13561387

@@ -1613,10 +1644,11 @@ export class DelegatedAdminGate {
16131644
* ({@link callerRead}): a row the caller cannot read answers exactly what an
16141645
* id naming no row answers — one refusal, here at the gate, never a
16151646
* fall-through to the engine. A row the caller reads is judged on the stored
1616-
* row, as before, never on the caller's read of it: that read is served
1617-
* through the caller's field-level security, and a judgement made on what a
1618-
* field rule hid would be made on less than the row. So a row the caller
1619-
* reads keeps its verdict and its guidance.
1647+
* row, never on the caller's read of it (ruling of record 6649064874): that
1648+
* read is served through the caller's field-level security, and a judgement
1649+
* made on what a field rule hid would be made on less than the row. So a row
1650+
* the caller reads keeps its verdict, and its guidance names only what the
1651+
* caller may be told ({@link mayName}).
16201652
*
16211653
* A read the read door REFUSES (a declared 4xx — the object's read grant
16221654
* withheld) hides no row: the stored row is judged as before, and a refusal

0 commit comments

Comments
 (0)