Skip to content

Commit 72d7db0

Browse files
fix(plugin-security): an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one (#29932)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:组织管理员(`organization_admin` / `organization_admin_no_bypass`)按 id 删除或修改授权行(`sys_user_position`、`sys_user_permission_set`)被拒时,对它读不到的行和根本不存在的 id 给出完全相同的回答(状态码、错误码、文案、details 全同)。委派管理闸门的组织分支改为以调用者自己的读取来判断要删除的那一行。 - 为什么改:落实 ruling 5985885287:调用者读不到的行与不存在的 id 回答一致,对所有主体类别都适用;本 PR 把这条规则落到组织管理员分支,与按 id 写入门的既有做法一致。 - 风险与回滚:只改组织管理员分支的前像读取来源;它能读到的行(本组织的行、`single` 姿态下的行)判定与提示不变,原本放行的删除仍放行,租户级管理员不经过此分支。回滚即撤销本 PR。 - 先看:`packages/plugins/plugin-security/src/delegated-admin-gate.ts` 的 `organizationTargets` 与 `organizationAdminFailure` 中空前像的拒绝;`org-admin-assignment-boundary.posture.test.ts` 末尾新增的 describe。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/org-admin-assignment-boundary.posture.test.ts`,预期全部通过(含 `group` / `isolated` 两个姿态下新增的用例)。 - 需要你做的:无,席位落地。 ## What changes - **The organization branch reads the row a removal addresses as the caller.** `DelegatedAdminGate.organizationAdminFailure` (ADR-0090 D12, amended 2026-10-17) now takes its targets from `organizationTargets`: an insert's payload rows, or the one row a by-id removal names, read through the engine under the caller's own context. - **No row is one answer.** When the caller's read returns no row, the removal is refused with the answer an id naming no row already got: `403 PERMISSION_DENIED`, "the assignment row to remove could not be read", with the same details. That refusal stays at the gate and never reaches the engine. - **A row the caller reads is judged as before.** The organization, business-unit and boundary checks run on the row the caller read, so its guidance (the organization it belongs to, the set outside the boundary and the condition it fails) is unchanged. An edit in place was already refused before any row is read, so update answers are unchanged. - **Read faults propagate as raised.** The caller read has no catch: a store fault is neither an answer nor a refusal, and a declared read refusal is what the by-id write door answers that caller anyway. - Changeset: `@objectstack/plugin-security` patch. ## Tests - **Engine level, real `ObjectQL` + SQL driver + `SecurityPlugin`** (`org-admin-assignment-boundary.posture.test.ts`, new top-level describe): - under `group` and `isolated`, for both administrator variants and both tables, the update and the delete of a row the caller cannot read (precondition: the caller's own read returns nothing) answer exactly what the same write answers for an id naming no row, compared as a whole (status, code, sentence, details), and the row stays; - control: a row of the caller's own organization that it reads keeps the boundary guidance and is not answered as no row; the tenant-level administrator is answered by the engine's not-found for both, as before; - control, `single`: a row of another organization that the caller reads keeps the guidance naming its organization. - **HTTP door** (`packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts`, showcase behind a real `isolated` wall): `PATCH` and `DELETE` of two assignment rows the organization owner cannot read answer exactly what an id naming no row answers; a row it reads keeps the guidance. - **One-shot ablation**, not kept: committed fix at d6b573a, the branch's `organizationTargets` call swapped back for the target walk it replaced (`node scripts/ablation-replace.mjs`, anchor hit 1 time, blob 589c61d → a91f1b91): `org-admin-assignment-boundary.posture.test.ts` 2 failed | 63 passed (the two walled postures' new case, `orgAdmin delete sys_user_position: the unreadable row answers as no row`); every control stayed green. Restored blob == HEAD blob 589c61d and `git diff HEAD` empty; the restored run 65/65. ## Acceptance notes - `delegated-admin-gate.test.ts`'s organization harness reads through a fake `ql` that ignores context, so its foreign-row case stands for a caller who reads the row, and its expectation (the guidance naming the organization) is unchanged, as the `single` control pins on the real engine. ## Verification <details><summary>Final head 51d9481 (main merged at b6fe08a, clean)</summary> - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/org-admin-assignment-boundary.posture.test.ts src/delegated-admin-gate.test.ts`: 2 files, 202/202. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (tsc, scripts tsc, test layer 0 errors). - Every command `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives: 64 commands, each exit 0, captured before any pipe; `--ran` reconciliation: 64 derived, 64 run, 0 NOT-MEASURED. `check:dual-build-cjs-loads` and `check:type-check-debt` ran with the lock held and the dist they read built. </details> <details><summary>At b6fe08a (the main merge; the two later commits change only the posture test's caller read)</summary> - `pnpm turbo run build --filter=@objectstack/dogfood --concurrency=2`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2` (full suite): 241 files, 5225 passed, 45 skipped. - `pnpm --filter @objectstack/dogfood exec vitest run --project isolated test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts`: 3/3. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - `pnpm --filter @objectstack/dogfood test` (full battery, once): 413 files passed, 3 skipped; 3415 tests passed, 28 skipped. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f47410 commit 72d7db0

4 files changed

Lines changed: 319 additions & 2 deletions

File tree

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 refused assignment write answers the same for a row it cannot read as for a missing one
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. The delegated-administration gate (ADR-0090 D12) now judges that row as the administrator itself reads it, in its own context.
11+
12+
- **A row the administrator cannot read** is refused with exactly the answer an id that names no row gets: `403 PERMISSION_DENIED`, "the assignment row to remove could not be read", with the same details.
13+
- **A row the administrator can read** keeps the answer it had, including the guidance that names the organization boundary.
14+
- **Unchanged.** An edit in place is refused as before, every removal that was admitted is still admitted, and a tenant-level administrator is judged as before.

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ function stampWriter(opCtx: any, userId: unknown): void {
220220
}
221221

222222
export interface DelegatedAdminGateDeps {
223-
/** ObjectQL engine handle (system-context reads for pre-images/lookups). */
223+
/** ObjectQL engine handle (pre-images and lookups). */
224224
ql: any;
225225
/** Shared permission-set resolution (same path as the CRUD middleware). */
226226
resolveSets: (context: any) => Promise<PermissionSet[]>;
@@ -756,6 +756,10 @@ export class DelegatedAdminGate {
756756
* Every permission set the row distributes must be inside the boundary
757757
* ({@link organizationBoundaryFailure}); for a position, every set it carries
758758
* ({@link setsCarriedByPosition}). One set outside puts the row outside.
759+
*
760+
* The row a removal targets is judged as the CALLER reads it
761+
* ({@link organizationTargets}), so a row the caller cannot read is answered
762+
* exactly as an id naming no row is (ruling of record 5985885287).
759763
*/
760764
private async organizationAdminFailure(
761765
opCtx: any,
@@ -778,8 +782,10 @@ export class DelegatedAdminGate {
778782
};
779783
}
780784

781-
for (const t of await this.materializeTargets(opCtx, opCtx.object)) {
785+
for (const t of await this.organizationTargets(opCtx)) {
782786
const row = t.next ?? t.prev;
787+
// No row answers an id naming none and a row the caller cannot read
788+
// alike: one refusal, and never a fall-through to the engine.
783789
if (!row) return { reason: 'the assignment row to remove could not be read', meta: {} };
784790

785791
const rowOrg = rowOrganizationId(row);
@@ -821,6 +827,29 @@ export class DelegatedAdminGate {
821827
return null;
822828
}
823829

830+
/**
831+
* [ADR-0090 D12; ruling of record 5985885287] The rows an organization
832+
* administrator's insert or removal targets: an insert's payload rows, or the
833+
* one row a removal addresses, as the CALLER reads it.
834+
*
835+
* The pre-image is the caller's own by-id read through the engine — the read
836+
* door's answer to this caller, every read visibility and wall included —
837+
* never a system read: a row the caller cannot read answers what an id naming
838+
* no row answers. A row it reads is judged on what it read, so its guidance
839+
* stays. A read refusal or a store fault propagates as raised: an outage is
840+
* neither an answer nor a refusal, and the by-id write door answers a caller
841+
* the read door refuses with that refusal.
842+
*/
843+
private async organizationTargets(opCtx: any): Promise<Array<{ next: any | null; prev: any | null }>> {
844+
if (opCtx.operation === 'insert') return rowsOf(opCtx).map((r) => ({ next: r, prev: null }));
845+
const id = extractSingleId(opCtx);
846+
const ql = this.deps.ql;
847+
const prev = id != null && ql?.findOne
848+
? await ql.findOne(opCtx.object, { where: { id }, context: opCtx.context })
849+
: null;
850+
return [{ next: null, prev: prev && typeof prev === 'object' ? prev : null }];
851+
}
852+
824853
/**
825854
* Is position `positionName` inside the organization boundary? `null` when
826855
* every set it carries is; otherwise the first failure, naming the set.

‎packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
* `status` and `code` through `resolveThrownHttpError`, the resolver both HTTP
1616
* doors answer with — and an out-of-boundary refusal also names its set and
1717
* the failed condition. Every admission reads the stored row back.
18+
*
19+
* At the foot: under the walled postures, an organization administrator's
20+
* update and delete of a row it cannot read answer exactly what an id naming
21+
* no row answers (ruling of record 5985885287) — the whole refusal, sentence
22+
* and details included; a row it reads keeps the boundary guidance, and a
23+
* tenant-level administrator is answered by the engine as before.
1824
*/
1925

2026
import { describe, it, expect, afterEach, vi } from 'vitest';
@@ -276,3 +282,111 @@ describe('[ADR-0090 D12, amended 2026-10-17] the organization administrator assi
276282
});
277283
}
278284
});
285+
286+
/** An id that names no row in either table. */
287+
const NOWHERE = 'zz_nowhere_0001';
288+
289+
/** Everything a refusal carries: the envelope both doors resolve, its sentence and its details. */
290+
async function answerOf(run: () => Promise<unknown>): Promise<Record<string, unknown>> {
291+
try {
292+
await run();
293+
return { status: 200 };
294+
} catch (e) {
295+
const r = resolveThrownHttpError(e);
296+
return { status: r.status, code: r.code, message: (e as Error).message, details: (e as any).details };
297+
}
298+
}
299+
300+
/** An in-boundary row of `organizationId`, written by the system. */
301+
async function seedIn(h: Harness, table: Table, id: string, organizationId: string, userId: string) {
302+
await h.engine.insert(table, { id, user_id: userId, ...TABLES[table].in, organization_id: organizationId },
303+
{ context: { isSystem: true, tenantId: organizationId } } as any);
304+
}
305+
306+
const ORG_ADMINS = ['orgAdmin', 'orgAdminNoBypass'] as const;
307+
308+
/** `caller`'s own by-id read of `id`: the read door's answer to it. */
309+
function readAs(h: Harness, table: Table, id: string, caller: Caller) {
310+
const { positions, permissions, accessible_org_ids, ...rest } = CALLERS[caller];
311+
const context = { ...rest, positions: [...positions], permissions: [...permissions], accessible_org_ids: [...accessible_org_ids] };
312+
return h.engine.findOne(table, { where: { id }, context });
313+
}
314+
315+
const write =(h: Harness, table: Table, op: 'update' | 'delete', id: string, context: object) =>
316+
answerOf(() => op === 'update'
317+
? h.engine.update(table, { reason: 'edited' }, { where: { id }, context } as any)
318+
: h.engine.delete(table, { where: { id }, context } as any));
319+
320+
describe("[ADR-0090 D12; ruling of record 5985885287] an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one", () => {
321+
for (const posture of ['group', 'isolated'] as const) {
322+
describe(`posture '${posture}'`, () => {
323+
it('update and delete of a row the caller cannot read answer exactly what an id naming no row answers, and leave the row', async () => {
324+
const h = await boot(posture);
325+
for (const table of Object.keys(TABLES) as Table[]) {
326+
const id = `unreadable_${table}`;
327+
await seedIn(h, table, id, ORG_B, 'u_other_org');
328+
const before = await stored(h, table, { id });
329+
expect(before, `${table}: the row exists`).toHaveLength(1);
330+
for (const caller of ORG_ADMINS) {
331+
const context = CALLERS[caller];
332+
// The precondition, read off the read door: this caller reads no such row.
333+
expect(await readAs(h, table, id, caller), `${caller} reads ${table} ${id}`).toBeFalsy();
334+
for (const op of ['update', 'delete'] as const) {
335+
const row = await write(h, table, op, id, context);
336+
const none = await write(h, table, op, NOWHERE, context);
337+
expect([none.status, none.code], `${caller} ${op} ${table}, no row: ${none.message}`).toEqual([403, 'PERMISSION_DENIED']);
338+
expect(row, `${caller} ${op} ${table}: the unreadable row answers as no row`).toEqual(none);
339+
}
340+
}
341+
expect(await stored(h, table, { id }), `${table}: refused means untouched`).toEqual(before);
342+
}
343+
});
344+
345+
it('control: a row of its own organization, which it reads, keeps the boundary guidance; the platform administrator is answered as before', async () => {
346+
const h = await boot(posture);
347+
for (const table of Object.keys(TABLES) as Table[]) {
348+
await seed(h, table, 'own_out', 'out');
349+
for (const caller of ORG_ADMINS) {
350+
const context = CALLERS[caller];
351+
expect(await readAs(h, table, 'own_out', caller), `${caller} reads ${table}`).toBeTruthy();
352+
const own = await write(h, table, 'delete', 'own_out', context);
353+
expect([own.status, own.code], `${caller} ${table}: ${own.message}`).toEqual([403, 'PERMISSION_DENIED']);
354+
expect(own.message).toMatch(/'admin_full_access'.*by name/);
355+
expect(own, `${caller} ${table}: a row it reads is not answered as no row`).not.toEqual(await write(h, table, 'delete', NOWHERE, context));
356+
}
357+
expect(await stored(h, table, { id: 'own_out' }), `${table}: refused means untouched`).toHaveLength(1);
358+
359+
// A tenant-level administrator passes the gate: the engine answers
360+
// its by-id writes, and the row it addresses outside its active
361+
// organization answers that engine's not-found, as no row does.
362+
const id = `platform_${table}`;
363+
await seedIn(h, table, id, ORG_B, 'u_other_org');
364+
for (const op of ['update', 'delete'] as const) {
365+
const row = await write(h, table, op, id, CALLERS.platform);
366+
const none = await write(h, table, op, NOWHERE, CALLERS.platform);
367+
expect([none.status, none.code], `platform ${op} ${table}: ${none.message}`).toEqual([404, 'RECORD_NOT_FOUND']);
368+
expect({ ...row, message: String(row.message).split(id).join('ID') }, `platform ${op} ${table}`)
369+
.toEqual({ ...none, message: String(none.message).split(NOWHERE).join('ID') });
370+
}
371+
expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1);
372+
}
373+
});
374+
});
375+
}
376+
377+
it("control, 'single': a row of another organization that the caller reads keeps the guidance that names its organization", async () => {
378+
const h = await boot('single');
379+
for (const table of Object.keys(TABLES) as Table[]) {
380+
const id = `readable_${table}`;
381+
await seedIn(h, table, id, ORG_B, 'u_other_org');
382+
for (const caller of ORG_ADMINS) {
383+
const context = CALLERS[caller];
384+
expect(await readAs(h, table, id, caller), `${caller} reads ${table}`).toBeTruthy();
385+
const row = await write(h, table, 'delete', id, context);
386+
expect([row.status, row.code], `${caller} ${table}: ${row.message}`).toEqual([403, 'PERMISSION_DENIED']);
387+
expect(row.message).toMatch(/belongs to organization 'org_b'/);
388+
}
389+
expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1);
390+
}
391+
});
392+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0090 D12; ruling of record 5985885287] An organization administrator's
4+
// refused assignment write answers the same for a row it cannot read as for a
5+
// missing one — measured at the HTTP data door of the showcase behind a real
6+
// organization wall (`multiTenant: 'posture-only'`, the `isolated` posture).
7+
//
8+
// ── what is pinned ─────────────────────────────────────────────────────────
9+
//
10+
// Organization A's owner holds `organization_admin` for A (the grant its owner
11+
// membership earns). Two assignment rows of organization B — B's owner's own
12+
// administrator grant (`sys_user_permission_set`) and a position B holds
13+
// (`sys_user_position`) — sit behind the wall, so A's owner reads neither.
14+
// For each, `PATCH` and `DELETE` by id answer exactly what the same request
15+
// answers for an id that names no row: status, code, sentence and details,
16+
// the one difference being the id the request itself carried. Both rows stay.
17+
//
18+
// Controls, so a green run is not "every removal is answered alike":
19+
//
20+
// - the wall is real (`isolated`, active, not degraded), A's owner is no
21+
// platform administrator, is active in A and reads neither of B's rows;
22+
// - a row of A that A's owner reads — its own administrator grant, which the
23+
// organization boundary keeps outside by name — keeps the boundary's
24+
// guidance, which names the set, and is not answered as no row; it stays.
25+
//
26+
// The engine-level matrix (both walled postures, both administrator variants,
27+
// the tenant-level administrator, the `single` control) is pinned in
28+
// `packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts`.
29+
30+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
31+
import { type VerifyStack } from '@objectstack/verify';
32+
import { bootShowcase } from './showcase-boot.js';
33+
34+
const SYS = { isSystem: true } as const;
35+
const OWNER_A = 'oa-unreadable-owner-a@verify.test';
36+
const OWNER_B = 'oa-unreadable-owner-b@verify.test';
37+
const MISSING = 'zz_no_such_row_0001';
38+
39+
interface Answer { status: number; body: unknown }
40+
41+
/** The answer with the caller-supplied id written out, so the answers for two ids compare. */
42+
const withoutId = (a: Answer, id: string): Answer =>
43+
JSON.parse(JSON.stringify(a).split(id).join('ID')) as Answer;
44+
45+
const codeOf = (a: Answer): unknown => (a.body as any)?.code ?? (a.body as any)?.error?.code;
46+
const idOf = (b: any): string => String(b?.id ?? b?.record?.id ?? b?.data?.id ?? '');
47+
48+
describe("behind the organization wall, an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one", () => {
49+
let stack: VerifyStack;
50+
let ql: any;
51+
let ownerATok: string;
52+
let orgA = '';
53+
let orgB = '';
54+
let ownerA = '';
55+
let grantA = '';
56+
let grantB = '';
57+
let positionB = '';
58+
59+
const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
60+
const res = await stack.api(path, {
61+
method,
62+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ownerATok}` },
63+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
64+
});
65+
let parsed: unknown;
66+
try { parsed = await res.json(); } catch { parsed = undefined; }
67+
return { status: res.status, body: parsed };
68+
};
69+
const write = (method: 'PATCH' | 'DELETE', object: string, id: string): Promise<Answer> =>
70+
call(method, `/data/${object}/${id}`, method === 'PATCH' ? { reason: 'edited' } : undefined);
71+
72+
const stored = (object: string, id: string) => ql.findOne(object, { where: { id }, context: SYS });
73+
const userId = async (email: string): Promise<string> =>
74+
String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id ?? '');
75+
/** The id of the organization-administrator grant `user` holds in `organizationId`. */
76+
const adminGrantOf = async (user: string, organizationId: string): Promise<string> => {
77+
const rows = await ql.find('sys_user_permission_set', {
78+
where: { user_id: user, permission_set: 'organization_admin', organization_id: organizationId },
79+
context: SYS,
80+
});
81+
return String((Array.isArray(rows) ? rows : [])[0]?.id ?? '');
82+
};
83+
84+
/** A new organization, created and made active by its owner. */
85+
const organization = async (token: string, name: string, slug: string): Promise<string> => {
86+
const created = await stack.apiAs(token, 'POST', '/auth/organization/create', { name, slug });
87+
expect(created.status, `org create ${slug}: ${await created.clone().text()}`).toBe(200);
88+
const id = String(((await created.json()) as { id: string }).id);
89+
const active = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId: id });
90+
expect(active.status, `set-active ${slug}: ${await active.clone().text()}`).toBe(200);
91+
return id;
92+
};
93+
94+
beforeAll(async () => {
95+
stack = await bootShowcase({ multiTenant: 'posture-only' });
96+
ql = await stack.kernel.getServiceAsync('objectql');
97+
98+
ownerATok = await stack.signUp(OWNER_A);
99+
orgA = await organization(ownerATok, 'Unreadable Row A', 'oa-unreadable-a');
100+
const ownerBTok = await stack.signUp(OWNER_B);
101+
orgB = await organization(ownerBTok, 'Unreadable Row B', 'oa-unreadable-b');
102+
103+
ownerA = await userId(OWNER_A);
104+
const ownerB = await userId(OWNER_B);
105+
grantA = await adminGrantOf(ownerA, orgA);
106+
grantB = await adminGrantOf(ownerB, orgB);
107+
positionB = idOf(await ql.insert('sys_user_position',
108+
{ user_id: ownerB, position: 'contributor', organization_id: orgB },
109+
{ context: { ...SYS, tenantId: orgB } }));
110+
expect(ownerA && grantA && grantB && positionB, 'every row carries an id').toBeTruthy();
111+
}, 300_000);
112+
113+
afterAll(async () => {
114+
await stack?.stop?.();
115+
});
116+
117+
it("guard the guard: a real wall; A's owner administers A, is no platform administrator, and reads neither of B's rows", async () => {
118+
const tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean; degraded: boolean }>('tenancy');
119+
expect([tenancy.posture, tenancy.isolationActive, tenancy.degraded]).toEqual(['isolated', true, false]);
120+
expect(orgA).not.toBe(orgB);
121+
const session = (await (await stack.apiAs(ownerATok, 'GET', '/auth/get-session')).json()) as any;
122+
expect(session.user?.isPlatformAdmin).not.toBe(true);
123+
expect(session.session?.activeOrganizationId).toBe(orgA);
124+
125+
for (const [object, id, organizationId] of [
126+
['sys_user_permission_set', grantB, orgB],
127+
['sys_user_position', positionB, orgB],
128+
]) {
129+
const got = await call('GET', `/data/${object}/${id}`);
130+
expect(got.status, `${object} of B — ${JSON.stringify(got.body)}`).toBe(404);
131+
expect((await stored(object, id))?.organization_id, `${object}: the row lives in B`).toBe(organizationId);
132+
}
133+
const own = await call('GET', `/data/sys_user_permission_set/${grantA}`);
134+
expect(own.status, `A's own grant — ${JSON.stringify(own.body)}`).toBe(200);
135+
});
136+
137+
it("PATCH and DELETE of a row it cannot read answer exactly what an id naming no row answers, and leave the row", async () => {
138+
for (const [object, id] of [['sys_user_permission_set', grantB], ['sys_user_position', positionB]]) {
139+
const before = await stored(object, id);
140+
for (const method of ['PATCH', 'DELETE'] as const) {
141+
const hit = await write(method, object, id);
142+
const miss = await write(method, object, MISSING);
143+
expect({ status: miss.status, code: codeOf(miss) }, `${method} ${object}, no row — ${JSON.stringify(miss.body)}`)
144+
.toEqual({ status: 403, code: 'PERMISSION_DENIED' });
145+
expect(withoutId(hit, id), `${method} ${object}: the unreadable row answers as no row`).toEqual(withoutId(miss, MISSING));
146+
}
147+
expect(await stored(object, id), `${object}: refused writes leave the row as it was`).toEqual(before);
148+
}
149+
});
150+
151+
it("control: a row of A that A's owner reads keeps the boundary's guidance, and is not answered as no row", async () => {
152+
const before = await stored('sys_user_permission_set', grantA);
153+
const own = await write('DELETE', 'sys_user_permission_set', grantA);
154+
const miss = await write('DELETE', 'sys_user_permission_set', MISSING);
155+
expect({ status: own.status, code: codeOf(own) }, JSON.stringify(own.body)).toEqual({ status: 403, code: 'PERMISSION_DENIED' });
156+
expect(JSON.stringify(own.body)).toMatch(/'organization_admin'.*by name/);
157+
expect(withoutId(own, grantA)).not.toEqual(withoutId(miss, MISSING));
158+
expect(await stored('sys_user_permission_set', grantA), 'the refused removal leaves the row').toEqual(before);
159+
});
160+
});

0 commit comments

Comments
 (0)