Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/org-admin-removal-caller-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one

Clause-②: no
Metadata-protocol: none

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.

- **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.
- **A row the administrator can read** keeps the answer it had, including the guidance that names the organization boundary.
- **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.
33 changes: 31 additions & 2 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ function stampWriter(opCtx: any, userId: unknown): void {
}

export interface DelegatedAdminGateDeps {
/** ObjectQL engine handle (system-context reads for pre-images/lookups). */
/** ObjectQL engine handle (pre-images and lookups). */
ql: any;
/** Shared permission-set resolution (same path as the CRUD middleware). */
resolveSets: (context: any) => Promise<PermissionSet[]>;
Expand Down Expand Up @@ -756,6 +756,10 @@ export class DelegatedAdminGate {
* Every permission set the row distributes must be inside the boundary
* ({@link organizationBoundaryFailure}); for a position, every set it carries
* ({@link setsCarriedByPosition}). One set outside puts the row outside.
*
* 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).
*/
private async organizationAdminFailure(
opCtx: any,
Expand All @@ -778,8 +782,10 @@ export class DelegatedAdminGate {
};
}

for (const t of await this.materializeTargets(opCtx, opCtx.object)) {
for (const t of await this.organizationTargets(opCtx)) {
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: {} };

const rowOrg = rowOrganizationId(row);
Expand Down Expand Up @@ -821,6 +827,29 @@ export class DelegatedAdminGate {
return null;
}

/**
* [ADR-0090 D12; ruling of record 5985885287] The rows an organization
* administrator's insert or removal targets: an insert's payload rows, or the
* one row a removal addresses, as the CALLER reads it.
*
* The pre-image is the caller's own by-id read through the engine — the read
* door's answer to this caller, every read visibility and wall included —
* never a system read: a row the caller cannot read answers what an id naming
* no row answers. A row it reads is judged on what it read, so its guidance
* stays. A read refusal or a store fault propagates as raised: an outage is
* neither an answer nor a refusal, and the by-id write door answers a caller
* the read door refuses with that refusal.
*/
private async organizationTargets(opCtx: any): Promise<Array<{ next: any | null; prev: any | null }>> {
if (opCtx.operation === 'insert') return rowsOf(opCtx).map((r) => ({ next: r, prev: null }));
const id = extractSingleId(opCtx);
const ql = this.deps.ql;
const prev = id != null && ql?.findOne
? await ql.findOne(opCtx.object, { where: { id }, context: opCtx.context })
: null;
return [{ next: null, prev: prev && typeof prev === 'object' ? prev : null }];
}

/**
* Is position `positionName` inside the organization boundary? `null` when
* every set it carries is; otherwise the first failure, naming the set.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
* `status` and `code` through `resolveThrownHttpError`, the resolver both HTTP
* doors answer with — and an out-of-boundary refusal also names its set and
* 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.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
Expand Down Expand Up @@ -276,3 +282,111 @@ describe('[ADR-0090 D12, amended 2026-10-17] the organization administrator assi
});
}
});

/** An id that names no row in either table. */
const NOWHERE = 'zz_nowhere_0001';

/** Everything a refusal carries: the envelope both doors resolve, its sentence and its details. */
async function answerOf(run: () => Promise<unknown>): Promise<Record<string, unknown>> {
try {
await run();
return { status: 200 };
} catch (e) {
const r = resolveThrownHttpError(e);
return { status: r.status, code: r.code, message: (e as Error).message, details: (e as any).details };
}
}

/** An in-boundary row of `organizationId`, written by the system. */
async function seedIn(h: Harness, table: Table, id: string, organizationId: string, userId: string) {
await h.engine.insert(table, { id, user_id: userId, ...TABLES[table].in, organization_id: organizationId },
{ context: { isSystem: true, tenantId: organizationId } } as any);
}

const ORG_ADMINS = ['orgAdmin', 'orgAdminNoBypass'] as const;

/** `caller`'s own by-id read of `id`: the read door's answer to it. */
function readAs(h: Harness, table: Table, id: string, caller: Caller) {
const { positions, permissions, accessible_org_ids, ...rest } = CALLERS[caller];
const context = { ...rest, positions: [...positions], permissions: [...permissions], accessible_org_ids: [...accessible_org_ids] };
return h.engine.findOne(table, { where: { id }, context });
}

const write =(h: Harness, table: Table, op: 'update' | 'delete', id: string, context: object) =>
answerOf(() => op === 'update'
? h.engine.update(table, { reason: 'edited' }, { where: { id }, context } as any)
: h.engine.delete(table, { where: { id }, context } as any));

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", () => {
for (const posture of ['group', 'isolated'] as const) {
describe(`posture '${posture}'`, () => {
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 () => {
const h = await boot(posture);
for (const table of Object.keys(TABLES) as Table[]) {
const id = `unreadable_${table}`;
await seedIn(h, table, id, ORG_B, 'u_other_org');
const before = await stored(h, table, { id });
expect(before, `${table}: the row exists`).toHaveLength(1);
for (const caller of ORG_ADMINS) {
const context = CALLERS[caller];
// The precondition, read off the read door: this caller reads no such row.
expect(await readAs(h, table, id, caller), `${caller} reads ${table} ${id}`).toBeFalsy();
for (const op of ['update', 'delete'] as const) {
const row = await write(h, table, op, id, context);
const none = await write(h, table, op, NOWHERE, context);
expect([none.status, none.code], `${caller} ${op} ${table}, no row: ${none.message}`).toEqual([403, 'PERMISSION_DENIED']);
expect(row, `${caller} ${op} ${table}: the unreadable row answers as no row`).toEqual(none);
}
}
expect(await stored(h, table, { id }), `${table}: refused means untouched`).toEqual(before);
}
});

it('control: a row of its own organization, which it reads, keeps the boundary guidance; the platform administrator is answered as before', async () => {
const h = await boot(posture);
for (const table of Object.keys(TABLES) as Table[]) {
await seed(h, table, 'own_out', 'out');
for (const caller of ORG_ADMINS) {
const context = CALLERS[caller];
expect(await readAs(h, table, 'own_out', caller), `${caller} reads ${table}`).toBeTruthy();
const own = await write(h, table, 'delete', 'own_out', context);
expect([own.status, own.code], `${caller} ${table}: ${own.message}`).toEqual([403, 'PERMISSION_DENIED']);
expect(own.message).toMatch(/'admin_full_access'.*by name/);
expect(own, `${caller} ${table}: a row it reads is not answered as no row`).not.toEqual(await write(h, table, 'delete', NOWHERE, context));
}
expect(await stored(h, table, { id: 'own_out' }), `${table}: refused means untouched`).toHaveLength(1);

// A tenant-level administrator passes the gate: the engine answers
// its by-id writes, and the row it addresses outside its active
// organization answers that engine's not-found, as no row does.
const id = `platform_${table}`;
await seedIn(h, table, id, ORG_B, 'u_other_org');
for (const op of ['update', 'delete'] as const) {
const row = await write(h, table, op, id, CALLERS.platform);
const none = await write(h, table, op, NOWHERE, CALLERS.platform);
expect([none.status, none.code], `platform ${op} ${table}: ${none.message}`).toEqual([404, 'RECORD_NOT_FOUND']);
expect({ ...row, message: String(row.message).split(id).join('ID') }, `platform ${op} ${table}`)
.toEqual({ ...none, message: String(none.message).split(NOWHERE).join('ID') });
}
expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1);
}
});
});
}

it("control, 'single': a row of another organization that the caller reads keeps the guidance that names its organization", async () => {
const h = await boot('single');
for (const table of Object.keys(TABLES) as Table[]) {
const id = `readable_${table}`;
await seedIn(h, table, id, ORG_B, 'u_other_org');
for (const caller of ORG_ADMINS) {
const context = CALLERS[caller];
expect(await readAs(h, table, id, caller), `${caller} reads ${table}`).toBeTruthy();
const row = await write(h, table, 'delete', id, context);
expect([row.status, row.code], `${caller} ${table}: ${row.message}`).toEqual([403, 'PERMISSION_DENIED']);
expect(row.message).toMatch(/belongs to organization 'org_b'/);
}
expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [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 — measured at the HTTP data door of the showcase behind a real
// organization wall (`multiTenant: 'posture-only'`, the `isolated` posture).
//
// ── what is pinned ─────────────────────────────────────────────────────────
//
// Organization A's owner holds `organization_admin` for A (the grant its owner
// membership earns). Two assignment rows of organization B — B's owner's own
// administrator grant (`sys_user_permission_set`) and a position B holds
// (`sys_user_position`) — sit behind the wall, so A's owner reads neither.
// For each, `PATCH` and `DELETE` by id answer exactly what the same request
// answers for an id that names no row: status, code, sentence and details,
// the one difference being the id the request itself carried. Both rows stay.
//
// Controls, so a green run is not "every removal is answered alike":
//
// - the wall is real (`isolated`, active, not degraded), A's owner is no
// platform administrator, is active in A and reads neither of B's rows;
// - a row of A that A's owner reads — its own administrator grant, which the
// organization boundary keeps outside by name — keeps the boundary's
// guidance, which names the set, and is not answered as no row; it stays.
//
// The engine-level matrix (both walled postures, both administrator variants,
// the tenant-level administrator, the `single` control) is pinned in
// `packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts`.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
import { bootShowcase } from './showcase-boot.js';

const SYS = { isSystem: true } as const;
const OWNER_A = 'oa-unreadable-owner-a@verify.test';
const OWNER_B = 'oa-unreadable-owner-b@verify.test';
const MISSING = 'zz_no_such_row_0001';

interface Answer { status: number; body: unknown }

/** The answer with the caller-supplied id written out, so the answers for two ids compare. */
const withoutId = (a: Answer, id: string): Answer =>
JSON.parse(JSON.stringify(a).split(id).join('ID')) as Answer;

const codeOf = (a: Answer): unknown => (a.body as any)?.code ?? (a.body as any)?.error?.code;
const idOf = (b: any): string => String(b?.id ?? b?.record?.id ?? b?.data?.id ?? '');

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", () => {
let stack: VerifyStack;
let ql: any;
let ownerATok: string;
let orgA = '';
let orgB = '';
let ownerA = '';
let grantA = '';
let grantB = '';
let positionB = '';

const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
const res = await stack.api(path, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ownerATok}` },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
let parsed: unknown;
try { parsed = await res.json(); } catch { parsed = undefined; }
return { status: res.status, body: parsed };
};
const write = (method: 'PATCH' | 'DELETE', object: string, id: string): Promise<Answer> =>
call(method, `/data/${object}/${id}`, method === 'PATCH' ? { reason: 'edited' } : undefined);

const stored = (object: string, id: string) => ql.findOne(object, { where: { id }, context: SYS });
const userId = async (email: string): Promise<string> =>
String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id ?? '');
/** The id of the organization-administrator grant `user` holds in `organizationId`. */
const adminGrantOf = async (user: string, organizationId: string): Promise<string> => {
const rows = await ql.find('sys_user_permission_set', {
where: { user_id: user, permission_set: 'organization_admin', organization_id: organizationId },
context: SYS,
});
return String((Array.isArray(rows) ? rows : [])[0]?.id ?? '');
};

/** A new organization, created and made active by its owner. */
const organization = async (token: string, name: string, slug: string): Promise<string> => {
const created = await stack.apiAs(token, 'POST', '/auth/organization/create', { name, slug });
expect(created.status, `org create ${slug}: ${await created.clone().text()}`).toBe(200);
const id = String(((await created.json()) as { id: string }).id);
const active = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId: id });
expect(active.status, `set-active ${slug}: ${await active.clone().text()}`).toBe(200);
return id;
};

beforeAll(async () => {
stack = await bootShowcase({ multiTenant: 'posture-only' });
ql = await stack.kernel.getServiceAsync('objectql');

ownerATok = await stack.signUp(OWNER_A);
orgA = await organization(ownerATok, 'Unreadable Row A', 'oa-unreadable-a');
const ownerBTok = await stack.signUp(OWNER_B);
orgB = await organization(ownerBTok, 'Unreadable Row B', 'oa-unreadable-b');

ownerA = await userId(OWNER_A);
const ownerB = await userId(OWNER_B);
grantA = await adminGrantOf(ownerA, orgA);
grantB = await adminGrantOf(ownerB, orgB);
positionB = idOf(await ql.insert('sys_user_position',
{ user_id: ownerB, position: 'contributor', organization_id: orgB },
{ context: { ...SYS, tenantId: orgB } }));
expect(ownerA && grantA && grantB && positionB, 'every row carries an id').toBeTruthy();
}, 300_000);

afterAll(async () => {
await stack?.stop?.();
});

it("guard the guard: a real wall; A's owner administers A, is no platform administrator, and reads neither of B's rows", async () => {
const tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean; degraded: boolean }>('tenancy');
expect([tenancy.posture, tenancy.isolationActive, tenancy.degraded]).toEqual(['isolated', true, false]);
expect(orgA).not.toBe(orgB);
const session = (await (await stack.apiAs(ownerATok, 'GET', '/auth/get-session')).json()) as any;
expect(session.user?.isPlatformAdmin).not.toBe(true);
expect(session.session?.activeOrganizationId).toBe(orgA);

for (const [object, id, organizationId] of [
['sys_user_permission_set', grantB, orgB],
['sys_user_position', positionB, orgB],
]) {
const got = await call('GET', `/data/${object}/${id}`);
expect(got.status, `${object} of B — ${JSON.stringify(got.body)}`).toBe(404);
expect((await stored(object, id))?.organization_id, `${object}: the row lives in B`).toBe(organizationId);
}
const own = await call('GET', `/data/sys_user_permission_set/${grantA}`);
expect(own.status, `A's own grant — ${JSON.stringify(own.body)}`).toBe(200);
});

it("PATCH and DELETE of a row it cannot read answer exactly what an id naming no row answers, and leave the row", async () => {
for (const [object, id] of [['sys_user_permission_set', grantB], ['sys_user_position', positionB]]) {
const before = await stored(object, id);
for (const method of ['PATCH', 'DELETE'] as const) {
const hit = await write(method, object, id);
const miss = await write(method, object, MISSING);
expect({ status: miss.status, code: codeOf(miss) }, `${method} ${object}, no row — ${JSON.stringify(miss.body)}`)
.toEqual({ status: 403, code: 'PERMISSION_DENIED' });
expect(withoutId(hit, id), `${method} ${object}: the unreadable row answers as no row`).toEqual(withoutId(miss, MISSING));
}
expect(await stored(object, id), `${object}: refused writes leave the row as it was`).toEqual(before);
}
});

it("control: a row of A that A's owner reads keeps the boundary's guidance, and is not answered as no row", async () => {
const before = await stored('sys_user_permission_set', grantA);
const own = await write('DELETE', 'sys_user_permission_set', grantA);
const miss = await write('DELETE', 'sys_user_permission_set', MISSING);
expect({ status: own.status, code: codeOf(own) }, JSON.stringify(own.body)).toEqual({ status: 403, code: 'PERMISSION_DENIED' });
expect(JSON.stringify(own.body)).toMatch(/'organization_admin'.*by name/);
expect(withoutId(own, grantA)).not.toEqual(withoutId(miss, MISSING));
expect(await stored('sys_user_permission_set', grantA), 'the refused removal leaves the row').toEqual(before);
});
});
Loading