|
| 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