From 1f3f87f4f1b1d2b7de10f36d3d341e74bfa6ba9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:39:52 +0000 Subject: [PATCH 01/11] wip(security): walled catalog refusal at the /meta doors and the data door (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../src/meta-write-capability.ts | 101 +++++++++++++++++ .../plugin-security/src/security-plugin.ts | 16 +++ .../src/walled-catalog-data-door.ts | 88 ++++++++++++++ packages/rest/src/rest-server.ts | 107 ++++++++++++++++++ .../runtime/src/domains/activation-gate.ts | 33 ++++-- packages/runtime/src/domains/meta.ts | 26 +++++ 6 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.ts diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index ed62187c0e0..0a07e04efb5 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -47,6 +47,9 @@ * about this one; the sentence varies only on the door's verb. */ +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { canonicalMetaUrlType } from '@objectstack/spec/meta-spelling'; + /** ADR-0066 D1's platform-wide metadata authoring capability. */ export const METADATA_AUTHORING_CAPABILITY = 'manage_metadata'; @@ -91,3 +94,101 @@ export function metaWriteCapabilityVerdict(input: { message: `${OPERATION_SUBJECT[input.operation]} requires the \`manage_metadata\` capability.`, }; } + +// ───────────────────────────────────────────────────────────────────────────── +// [ADR-0131 D3] The walled catalog refusal +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The security catalog (ADR-0131 D3): the three metadata types whose items are + * definitions every organization resolves BY NAME, from the one environment + * registry. A walled deployment's organizations share them, so a write to one + * reaches every organization that holds it. + */ +export const SECURITY_CATALOG_TYPES: readonly string[] = Object.freeze(['position', 'permission', 'capability']); + +/** + * Is `type` — in any URL spelling `saveMetaItem` accepts — a security catalog + * type? A door asks this before it pays for the tenancy posture read that + * {@link walledCatalogWriteVerdict} needs, so a write of any other type reads + * no posture at all. + */ +export function isSecurityCatalogType(type: string): boolean { + return SECURITY_CATALOG_TYPES.includes(canonicalMetaUrlType(type)); +} + +/** The noun the refusal names, per catalog type. */ +const CATALOG_NOUN: Readonly> = { + position: 'a position', + permission: 'a permission set', + capability: 'a capability', +}; + +/** + * The ADR-0095 D2/D3 posture rung that IS the platform operator (ADR-0068 D2: + * "Platform operator (SaaS admin). NOT a tenant user role"). The activation + * gate (`@objectstack/runtime` `domains/activation-gate.ts`) reads the same + * rung for the same reason: it is derived from the platform-admin anchors and + * never from a held capability or a position name, so a tenant holding + * `manage_metadata` inside its organization does not reach it. + */ +export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN'; + +/** + * [ADR-0131 D3] May this caller write this item of the security catalog, on a + * deployment running this tenancy posture? + * + * ADR-0131 D3, verbatim: "Shared-DB multi-tenant (`group` / `isolated`): tenant + * admins cannot create or edit catalog items; the operator defines the catalog + * for every tenant (managed packages, Studio). Creation through Setup or the + * API is refused with a message naming the posture and the capability." The + * catalog is environment-level and organizations resolve it by name, so under + * a wall a write by one organization's principal changes what every other + * organization's members resolve. That was measured, not argued: an + * organization-scoped `manage_metadata` holder edited a permission set another + * organization's member holds, and that member's next resolution carried the + * edit (`wall-catalog-refusal.dogfood.test.ts`). + * + * This is the ONE predicate for that refusal. Every door that writes a catalog + * item asks it, after its own capability gate: the `/meta` item write doors on + * both transports (save, reset, publish, rollback) and the security plugin's + * data door on the catalog objects. Under `single` it is inert: the + * organization is the environment, and the capability gate decides alone + * (#22621 → A: catalog writes stay with `manage_metadata` holders). + * + * Admitted under a wall: `isSystem` (the engine's own writes: seeders, + * installers, migrations) and the platform operator rung. Refused: everyone + * else, whatever they hold. An ABSENT posture (no `tenancy` service) is inert, + * as at the activation gate: ADR-0093 D4/D5 resolve a requested-but-absent wall + * to `single`, so absent and `single` are the same deployment shape. + * + * Assignments are not catalog writes and never reach this predicate: + * `sys_user_position` and `sys_user_permission_set` rows are organization rows + * (ADR-0131 D3, "Assignments are organization rows, in every posture"). + */ +export function walledCatalogWriteVerdict(input: { + /** The metadata type written. A type outside {@link SECURITY_CATALOG_TYPES} is always admitted. */ + type: string; + /** The deployment's EFFECTIVE tenancy posture; `undefined` when no `tenancy` service answers. */ + tenancyPosture: TenancyPosture | undefined; + isSystem?: boolean; + /** The caller's ADR-0095 posture rung, off its execution context. */ + callerPosture?: unknown; +}): MetaWriteCapabilityVerdict { + // The fold `saveMetaItem` applies at its own request boundary, so a URL + // spelling the protocol accepts (a plural) is judged as the type it writes. + if (!isSecurityCatalogType(input.type)) return { allowed: true }; + const type = canonicalMetaUrlType(input.type); + if (!input.tenancyPosture || !postureEnforcesWall(input.tenancyPosture)) return { allowed: true }; + if (input.isSystem === true) return { allowed: true }; + if (input.callerPosture === PLATFORM_OPERATOR_POSTURE) return { allowed: true }; + return { + allowed: false, + message: + `Creating, changing or deleting ${CATALOG_NOUN[type]} requires the platform operator: this ` + + `deployment runs the '${input.tenancyPosture}' tenancy posture, where the security catalog is shared ` + + `by every organization (ADR-0131 D3). Holding the \`manage_metadata\` capability inside an ` + + `organization does not admit it. An organization's administrators assign the catalog's positions ` + + `and permission sets to their own members instead.`, + }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 12449d326a2..cdb8d1e7c8d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -42,6 +42,7 @@ import { } from './grant-permission-set-name.js'; import { runOneTimeGrantPermissionSetNameBackfill } from './grant-permission-set-name-backfill.js'; import { createPositionWriteThrough } from './position-write-through.js'; +import { createWalledCatalogDataDoorRefusal, WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; import { runOneTimePositionEnvironmentBackfill } from './position-environment-backfill.js'; import { reportCatalogReferences } from './catalog-reference-report.js'; import { @@ -4342,6 +4343,21 @@ export class SecurityPlugin implements Plugin { ); } + // [ADR-0131 D3] Under a wall the security catalog is the platform + // operator's: a non-system write on `sys_position`, `sys_permission_set` or + // `sys_capability` by anyone below the `PLATFORM_ADMIN` rung is refused + // `403 PERMISSION_DENIED`, with the metadata door's own verdict and + // sentence. Registered AFTER the security middleware (a caller who may not + // write the table keeps its CRUD refusal) and BEFORE the permission-set + // write-through below (a refused write is never translated into a metadata + // save). Inert under `single`. See `walled-catalog-data-door.ts`. + { + const walledCatalogDataDoor = createWalledCatalogDataDoorRefusal({ getPosture: () => this.tenancyPosture }); + for (const object of Object.keys(WALLED_CATALOG_OBJECT_TYPES)) { + ql.registerMiddleware(walledCatalogDataDoor, { object }); + } + } + // [ADR-0094] Data-door write-through: every non-system CRUD write on // `sys_permission_set` is redirected into the metadata store (the ONE // authoritative store for definitions); the record is projector-owned. diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.ts new file mode 100644 index 00000000000..d10cc2a76ee --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] The walled catalog refusal at the DATA door: a non-system + * insert, update or delete on `sys_position`, `sys_permission_set` or + * `sys_capability`, under a posture that enforces an organization wall, by a + * caller who is not the platform operator, is refused `403 PERMISSION_DENIED`. + * + * ## Why the data door needs it + * + * ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins + * cannot create or edit catalog items … Creation through Setup or the API is + * refused". Setup writes the catalog through these three objects. Under a wall + * the stock organization administrator is already refused there by its own + * grants (the anti-escalation deny in `objects/default-permission-sets.ts`), + * but any other organization-scoped principal whose sets admit the table was + * not: + * + * - a `sys_permission_set` write is redirected into the environment ledger + * (`createPermissionSetWriteThrough`, ADR-0094 D3) in every posture, so an + * organization-scoped principal created an ENVIRONMENT permission set every + * organization resolves by name — measured on a booted walled stack; + * - a `sys_position` write landed as a row of the writer's organization, a + * position with no registry home (the `single`-only write-through stands + * down under a wall) — measured in this package's harness. + * + * The verdict and its sentence are the metadata door's own + * (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`), so the data + * door and the `/meta` doors refuse the same callers with the same words. + * + * ## Where it runs + * + * Registered AFTER the security middleware, so it runs INSIDE it: a caller who + * may not write the table at all keeps its CRUD refusal and learns nothing + * here. Registered BEFORE the permission-set write-through, so a refused write + * is never translated into a metadata save. + * + * ## What it does not judge + * + * - **Assignments.** `sys_user_position` and `sys_user_permission_set` rows + * are organization rows in every posture (ADR-0131 D3); an organization's + * administrators keep assigning the catalog to their own members. + * - **System writes** (`isSystem`): the seeders, the package door, migrations. + * - **The platform operator** (the ADR-0095 `PLATFORM_ADMIN` rung on the + * context). A hand-built context that carries no rung is not the operator. + * - **`single`**: inert; the organization is the environment. + */ + +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +import type { TenancyPosture } from '@objectstack/spec/security'; +import { PermissionDeniedError } from './errors.js'; + +/** The catalog objects this guard judges, and the metadata type each one writes. */ +export const WALLED_CATALOG_OBJECT_TYPES: Readonly> = Object.freeze({ + sys_position: 'position', + sys_permission_set: 'permission', + sys_capability: 'capability', +}); + +/** The engine operations that change a catalog row. */ +const WRITE_OPERATIONS: ReadonlySet = new Set(['insert', 'update', 'delete']); + +export interface WalledCatalogDataDoorDeps { + /** The deployment's effective tenancy posture, as the security plugin resolved it. */ + getPosture: () => TenancyPosture | undefined; +} + +/** The engine middleware. Registered once per catalog object (see {@link WALLED_CATALOG_OBJECT_TYPES}). */ +export function createWalledCatalogDataDoorRefusal( + deps: WalledCatalogDataDoorDeps, +): (opCtx: any, next: () => Promise) => Promise { + return async (opCtx: any, next: () => Promise): Promise => { + const object = opCtx?.object; + const type = typeof object === 'string' ? WALLED_CATALOG_OBJECT_TYPES[object] : undefined; + if (!type || !WRITE_OPERATIONS.has(opCtx?.operation)) return next(); + const context = opCtx?.context ?? {}; + const verdict = walledCatalogWriteVerdict({ + type, + tenancyPosture: deps.getPosture(), + isSystem: context.isSystem === true, + callerPosture: context.posture, + }); + if (!verdict.allowed) { + throw new PermissionDeniedError(verdict.message, { operation: opCtx.operation, object }); + } + return next(); + }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 80f0e594db9..c1443c9f525 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -15,6 +15,7 @@ import { // every other rejection stays loud. ⛔ The two wirings themselves are NOT // the helper's — see `computeExecCtx`. classifyAdmissionTenancyPosture, + effectiveTenancyPosture, // The registry's "never registered" brand, asked directly by the public // form doors' tenancy read (see `registerFormEndpoints`). isServiceNotRegisteredError, @@ -72,6 +73,9 @@ import { // environment-wide and every read resolves environment → code. metaWriteCapabilityVerdict, type MetaWriteCapabilityVerdict, + // [ADR-0131 D3] The walled catalog refusal the same four doors ask next. + isSecurityCatalogType, + walledCatalogWriteVerdict, // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, @@ -3621,6 +3625,33 @@ export class RestServer { */ private static readonly STORED_VERSION_DOOR_POLICY: MetaReadGatePolicy = metaReadGate.STORED_VERSION_DOOR_POLICY; + /** + * [ADR-0131 D3] The walled catalog refusal for one `/meta` item write: the + * shared `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), asked + * by this transport's four write doors (save, reset, publish, rollback) + * after {@link metaSaveVerdict}'s capability gate. A type outside the + * security catalog is admitted without reading the posture. The posture is + * read through {@link resolveAnonymousFormTenancy}, the wiring that mirrors + * `resolveProtocol`, so the posture judged and the protocol that writes + * always come from the same kernel; a `tenancy` service that is registered + * and cannot answer raises `AuthzStoreUnavailableError` (the door's 503), + * never a permit. + */ + private async walledCatalogRefusal( + type: string, + ctx: any, + environmentId: string | undefined, + req: any, + ): Promise { + if (!isSecurityCatalogType(type)) return { allowed: true }; + return walledCatalogWriteVerdict({ + type, + tenancyPosture: effectiveTenancyPosture(await this.resolveAnonymousFormTenancy(environmentId, req)), + isSystem: ctx?.isSystem === true, + callerPosture: ctx?.posture, + }); + } + /** * [#12702 · #20156] May this caller SAVE `:type/:name`? The admission of * `PUT /meta/:type/:name`, spelled ONCE: that door asks it, and so does the @@ -6933,6 +6964,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!p.saveMetaItem) { // [#7035] ADR-0112 envelope: the semantic code lives at @@ -7131,6 +7181,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!p.deleteMetaItem) { // [#7035] ADR-0112 envelope. This site was the worst of @@ -7567,6 +7636,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!p.publishMetaItem) { res.status(501).json({ @@ -7729,6 +7817,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!(p as any).rollbackMetaItem) { res.status(501).json({ diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 0e2e2a4473a..3724549bfa4 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -219,6 +219,30 @@ export function readActivationBody( return { enabled: (toggleBody as { enabled?: boolean }).enabled ?? true }; } +/** + * The deployment's EFFECTIVE tenancy posture for this request, read the way + * {@link refuseUngrantedActivationWrite} reads it (the reasoning lives in that + * function's body): `undefined` when no `tenancy` service is registered — the + * same deployment shape as `single` — and a loud + * `AuthzStoreUnavailableError('tenancy')` when one is registered and cannot + * answer, because the posture is an authorization input and was never decided. + * [ADR-0131 D3] The metadata door's walled catalog refusal reads it through + * this same function, so the two posture-conditional gates of this transport + * cannot read two different postures. + */ +export async function readEffectiveTenancyPosture( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): Promise> { + try { + return effectiveTenancyPosture( + await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId), + ); + } catch (err) { + throw new AuthzStoreUnavailableError('tenancy', err); + } +} + /** * The §5 gate itself. * @@ -298,14 +322,7 @@ export async function refuseUngrantedActivationWrite( // identity step and `./keys.ts` already resolve this exact name with, so // all three read one deployment's posture through one scope: an outage // answered here is the service's, never this call site's omission. - let posture; - try { - posture = effectiveTenancyPosture( - await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId), - ); - } catch (err) { - throw new AuthzStoreUnavailableError('tenancy', err); - } + const posture = await readEffectiveTenancyPosture(deps, context); if (!posture || !postureEnforcesWall(posture)) return undefined; // [ADR-0095 D2/D3 · #15981] The platform-operator test is the posture RUNG. diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 513c7aa0667..ba9d99286bf 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -31,6 +31,10 @@ import { // too. No `/meta` branch here carries an organization into a metadata // write or read: the per-organization overlay axis is retired. metaWriteCapabilityVerdict, + // [ADR-0131 D3] …and, for the security catalog under a wall, the platform + // operator alone: the walled catalog refusal every catalog write door asks. + isSecurityCatalogType, + walledCatalogWriteVerdict, } from '@objectstack/metadata-core'; // [#15238] The DECLARED protocol contracts this domain's request literals are // compiled against. Imported, never restated: a second hand-written @@ -90,6 +94,7 @@ import { type MetaReadGatePolicy, } from '@objectstack/rest'; import { buildApiError } from '../error-envelope.js'; +import { readEffectiveTenancyPosture } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -1301,6 +1306,27 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } + // [ADR-0131 D3] The walled catalog refusal, right after the + // capability gate and before anything is read or written: under + // `isolated` / `group` a position, permission set or capability is + // shared by every organization, so only the platform operator rung + // (or the engine itself) saves one. Inert under `single`. The + // posture is read only for a catalog type, so no other save pays + // for it. Same predicate as `RestServer`'s four write doors and the + // security plugin's data door. + if (isSecurityCatalogType(type)) { + const ec: any = _context.executionContext; + const walled = walledCatalogWriteVerdict({ + type, + tenancyPosture: await readEffectiveTenancyPosture(deps, _context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!walled.allowed) { + return { handled: true, response: deps.error(walled.message, 403) }; + } + } + // [#22141] The ADR-0008 precondition and the ADR-0033 lifecycle, // read off THIS request through the one mapping `RestServer`'s // `PUT` door reads them through (`metaSaveRequestOptions`, From d039e4d88d576e950da97c4a7c4f7d6b4d222258 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:52:46 +0000 Subject: [PATCH 02/11] test(security): pin the walled catalog refusal on isolated and group, with single and assignment controls (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../src/meta-write-capability.test.ts | 68 ++++++ .../src/builtin-positions.boot.test.ts | 20 +- .../src/position-write-through.test.ts | 20 +- .../src/walled-catalog-data-door.test.ts | 227 ++++++++++++++++++ .../test/wall-catalog-refusal.dogfood.test.ts | 225 +++++++++++++++++ 5 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts create mode 100644 packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts diff --git a/packages/metadata-core/src/meta-write-capability.test.ts b/packages/metadata-core/src/meta-write-capability.test.ts index eb6a837b27b..6ff950d546e 100644 --- a/packages/metadata-core/src/meta-write-capability.test.ts +++ b/packages/metadata-core/src/meta-write-capability.test.ts @@ -23,6 +23,10 @@ import * as metadataCore from './index.js'; import { METADATA_AUTHORING_CAPABILITY, metaWriteCapabilityVerdict, + isSecurityCatalogType, + PLATFORM_OPERATOR_POSTURE, + SECURITY_CATALOG_TYPES, + walledCatalogWriteVerdict, type MetaWriteOperation, } from './meta-write-capability.js'; @@ -92,3 +96,67 @@ describe('metaWriteCapabilityVerdict — who is admitted', () => { expect(metaWriteCapabilityVerdict({ isSystem: false, operation: 'rollback' }).allowed).toBe(false); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// [ADR-0131 D3] The walled catalog refusal +// ───────────────────────────────────────────────────────────────────────────── + +describe('[ADR-0131 D3] walledCatalogWriteVerdict — under a wall the security catalog is the platform operator\'s', () => { + const TENANT = { isSystem: false, callerPosture: 'TENANT_ADMIN' }; + + it('refuses a non-operator write of each catalog type under `isolated` and `group`', () => { + for (const tenancyPosture of ['isolated', 'group'] as const) { + for (const type of SECURITY_CATALOG_TYPES) { + const v = walledCatalogWriteVerdict({ type, tenancyPosture, ...TENANT }); + expect(v.allowed, `${type} under ${tenancyPosture}`).toBe(false); + } + } + }); + + it('the sentence names the posture and the capability, and nothing about the caller', () => { + const v = walledCatalogWriteVerdict({ type: 'permission', tenancyPosture: 'group', ...TENANT }); + expect(v.allowed).toBe(false); + const message = (v as { message: string }).message; + expect(message.split(': ')[0]).toBe('Creating, changing or deleting a permission set requires the platform operator'); + expect(message).toContain("'group' tenancy posture"); + expect(message).toContain('`manage_metadata`'); + expect(message).not.toContain('TENANT_ADMIN'); + }); + + it('a URL spelling the protocol folds to a catalog type is judged as that type', () => { + for (const type of ['positions', 'permissions', 'capabilities']) { + expect(isSecurityCatalogType(type), type).toBe(true); + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'isolated', ...TENANT }).allowed, type).toBe(false); + } + }); + + it('admits the platform operator rung and a system write', () => { + expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated', callerPosture: PLATFORM_OPERATOR_POSTURE })) + .toEqual({ allowed: true }); + expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated', isSystem: true })) + .toEqual({ allowed: true }); + }); + + it('a caller with no rung at all is not the operator', () => { + expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated' }).allowed).toBe(false); + }); + + it('is inert under `single` and with no posture (no `tenancy` service): the capability gate decides alone', () => { + for (const tenancyPosture of ['single', undefined] as const) { + for (const type of SECURITY_CATALOG_TYPES) { + expect(walledCatalogWriteVerdict({ type, tenancyPosture, ...TENANT })).toEqual({ allowed: true }); + } + } + }); + + it('admits every other type under a wall — the refusal is the catalog\'s, not the metadata door\'s', () => { + for (const type of ['object', 'view', 'flow', 'app']) { + expect(isSecurityCatalogType(type), type).toBe(false); + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'isolated', ...TENANT })).toEqual({ allowed: true }); + } + }); + + it('the catalog is exactly positions, permission sets and capabilities', () => { + expect([...SECURITY_CATALOG_TYPES]).toEqual(['position', 'permission', 'capability']); + }); +}); diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a0291771532..11adcea9334 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -515,12 +515,13 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { } /** - * The tenant position writes — the same in both postures. A name the security + * The tenant position writes. A name the security * catalog does not hold, a dangling one or a row with no definition alike, is * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two * built-in rows an existing database holds are refused on their `platform` - * provenance. + * provenance. Under a wall the tenant's position create is refused as well + * ({@link refusalGoldenFor}). */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -551,10 +552,19 @@ const REFUSAL_GOLDEN: Record = { }, }; -/** {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing database's rows. */ +/** + * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing + * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is + * refused (`walled-catalog-data-door.ts`): the catalog is the platform + * operator's, and a tenant may only assign it. The assignment cases do not + * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. + */ function refusalGoldenFor(scenario: Scenario): Record { - if (scenario.existingRows) return REFUSAL_GOLDEN; - const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = REFUSAL_GOLDEN; + const golden: Record = scenario.posture === 'single' + ? REFUSAL_GOLDEN + : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; + if (scenario.existingRows) return golden; + const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; return fresh; } diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index 6de62903f68..bbea2fce923 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -712,11 +712,17 @@ describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup posi }); describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it was', () => { - it('a walled posture: an organization administrator\'s create lands its organization row, and no definition', async () => { + // [ADR-0131 D3] Under a wall the catalog is the platform operator's: an + // organization administrator's Setup create is refused at the data door + // (`walled-catalog-data-door.ts`) before any row or definition exists. It + // used to land a row of the organization with no definition; the refusal + // and its assignment control are pinned in `walled-catalog-data-door.test.ts`. + it('a walled posture: an organization administrator\'s create is refused 403, with no row and no definition', async () => { const b = await boot({ posture: 'isolated' }); const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - await create(b, { name: 'plant_lead', label: 'Plant lead' }); - expect((await b.rows('plant_lead')).map((r) => r.organization_id)).toEqual([ORG]); + expect(envelope(await refusalOf(create(b, { name: 'plant_lead', label: 'Plant lead' })))) + .toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await b.rows('plant_lead')).toEqual([]); expect(saves).not.toHaveBeenCalled(); expect(await b.envRows('plant_lead')).toEqual([]); }); @@ -725,9 +731,15 @@ describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it // under a wall an organization's Setup position is a row with no definition, // so it grants nothing; the metadata door takes an environment definition // under the same name, and that definition is what the catalog then serves. + // + // The row-only position is the population a wall's Setup created before the + // walled catalog refusal (#15196 Q3 = A), so it is seeded here as that + // existing row, a system write into the organization: a tenant can no longer + // create one. it('a walled posture: an organization\'s row-only position is promoted by an environment definition saved at the metadata door', async () => { const b = await boot({ posture: 'isolated' }); - await create(b, { name: 'plant_auditor', label: 'Plant auditor' }); + await b.engine.insert('sys_position', { name: 'plant_auditor', label: 'Plant auditor' }, + { context: { isSystem: true, tenantId: ORG } } as any); expect(await b.catalogResolves('plant_auditor')).toBe(false); await (b.protocol as any).saveMetaItem({ type: 'position', name: 'plant_auditor', item: { name: 'plant_auditor', label: 'Plant auditor', permissionSets: ['plant_tools'] }, diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts new file mode 100644 index 00000000000..ee23104f024 --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the + * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). + * + * Measured before it on this same harness shape (a real `ObjectQL` over a real + * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the + * organization-scoping service): an organization-bound administrator holding + * the superuser wildcard created a position at the data door, and it landed + * as a row of that organization. Nothing judged a catalog create by posture. + * + * Pinned here, per posture: + * - `isolated` and `group`: the tenant's create, update and delete of a + * position, a permission set and a capability are refused with the ADR-0112 + * envelope `403 PERMISSION_DENIED`, and nothing is stored; + * - the CONTROL: the same tenant still assigns a catalog position to a member + * of its organization; + * - the platform operator rung and a system write are admitted; + * - `single`: the same tenant's create is accepted, unchanged. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SysCapability } from './objects/sys-capability.object.js'; +import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; + +const ORG = 'org_a'; + +/** The fallback every authenticated caller resolves: it grants nothing here. */ +const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; + +/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +type Posture = 'single' | 'isolated' | 'group'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.walled-catalog-data-door', + name: 'Walled catalog data door', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + SysCapability, SysMember, SysMetadataActivation, + ], + } as any); + await engine.syncSchemas(); + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, QA_ADMIN], + }, + ...(posture === 'single' + ? {} + : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const walled = posture !== 'single'; + const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; + // The catalog the tenant may assign: one position, declared in the registry. + engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, + 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); + // Rows the tenant will try to change: one per catalog object. + await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); + await engine.insert('sys_permission_set', { + id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', + object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, + }, { context: system } as any); + await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); + // The member an assignment names: a member of the organization it is scoped to. + await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, + { context: { isSystem: true, tenantId: ORG } } as any); + + const tenant = walled + ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } + : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; + return { engine, tenant, system }; +} + +async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { + try { + await run; + return { ok: true }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { ok: false, code: r.code, status: r.status }; + } +} + +const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; + +async function storedNames(engine: ObjectQL, object: string, name: string): Promise { + const rows = await engine.find(object, { where: { name }, context: { isSystem: true } } as any); + return Array.isArray(rows) ? rows.length : 0; +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { + it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); + expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); + expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); + }); + + it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) + .toEqual(REFUSED); + const [row] = await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } } as any) as any[]; + expect(row?.label).toBe('Existing'); + expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); + }); + + it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) + .toEqual({ ok: true }); + }); + + it('the platform operator rung and a system write are admitted', async () => { + const { engine, tenant, system } = await boot(posture); + const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) + .toEqual({ ok: true }); + }); + + it('a tenant rung below the operator is refused like a context that carries none', async () => { + const { engine, tenant } = await boot(posture); + const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) + .toEqual(REFUSED); + }); + }); +} + +describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { + it('the same administrator creates a position and a permission set', async () => { + const { engine, tenant } = await boot('single'); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); + }); +}); + +describe('the judged objects', () => { + it('are exactly the three security catalog objects — assignment objects are never judged', () => { + expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ + sys_position: 'position', + sys_permission_set: 'permission', + sys_capability: 'capability', + }); + }); +}); diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts new file mode 100644 index 00000000000..7df2a89ed89 --- /dev/null +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3] The walled catalog refusal, on a REAL booted stack. +// +// ADR-0131 D3: under `group` / `isolated` "tenant admins cannot create or edit +// catalog items; the operator defines the catalog for every tenant … Creation +// through Setup or the API is refused with a message naming the posture and +// the capability." The catalog (positions, permission sets, capabilities) is +// environment-level, and every organization resolves it by name. +// +// What this file measured before the refusal, on this same harness, under both +// walled postures: organization A's owner, granted `manage_metadata` inside +// organization A only (an organization-scoped grant, never the platform +// operator rung), saved `PUT /meta/permission/:name` over an environment +// permission set organization B's member holds, adding `manage_users`; B's +// member's next resolution carried `manage_users`. One organization's write +// changed what another organization's members resolve. The same principal +// created and deleted catalog items at `/meta`, and a holder of CRUD on +// `sys_permission_set` created an environment permission set through the data +// door's write-through. +// +// Pinned here: +// - walled: org A's catalog writes at `/meta` (save, create, delete) answer +// `403 FORBIDDEN`, and B's resolution does not move; +// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED`, +// and nothing reaches the environment ledger; +// - walled CONTROLS: org A still assigns a catalog permission set to its own +// member, and the platform operator still saves the catalog; +// - `single` CONTROL: a `manage_metadata` holder who is not the platform +// administrator still saves a position (#22621 → A, unchanged). +// +// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled +// posture. The refusal keys on the posture and the caller's rung, never on row +// isolation, so the stand-in's lack of a row wall measures nothing away here. +// `OS_TENANCY_POSTURE` selects `group`; the harness declares the seeded admin +// as `OS_PLATFORM_OWNER_EMAIL`, so that admin is the platform operator. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; + +interface Engine { + find(object: string, options?: unknown): Promise>>; + insert(object: string, data: Record, options?: unknown): Promise; +} + +/** The ADR-0112 envelope a refusal answered: `error.code` and the HTTP status. */ +async function envelopeOf(res: Response): Promise<{ status: number; code: unknown }> { + const body = (await res.clone().json().catch(() => ({}))) as { error?: { code?: unknown }; code?: unknown }; + return { status: res.status, code: body?.error?.code ?? body?.code }; +} + +const META_AUTHOR = 'w_meta_author'; +const SHARED = 'w_shared'; +const RBAC_WRITER = 'w_rbac_writer'; + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3] ${posture}: the security catalog is the platform operator's`, () => { + let stack: VerifyStack; + let ql: Engine; + let operator: string; + let tokA: string; + let orgA: string; + let orgB: string; + let userA: string; + let userB: string; + const previous = process.env.OS_TENANCY_POSTURE; + + const idOf = async (email: string) => + String((await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]?.id); + const bSystemPermissions = async () => + (await resolveUserAuthzGrants(ql as never, userB, { tenantId: orgB })).systemPermissions; + const definitionOf = async (type: string, name: string) => { + const res = await stack.apiAs(operator, 'GET', `/meta/${type}/${name}`); + return res.status === 200 ? ((await res.json()) as any) : null; + }; + + beforeAll(async () => { + process.env.OS_TENANCY_POSTURE = posture; + stack = await bootStack(crmStack as never, { multiTenant: 'posture-only' }); + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + operator = await stack.signIn(); + tokA = await stack.signUp(`w-a-${posture}@wall.test`); + const tokB = await stack.signUp(`w-b-${posture}@wall.test`); + for (const [tok, slug] of [[tokA, `w-a-${posture}`], [tokB, `w-b-${posture}`]] as const) { + const created = await stack.apiAs(tok, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + const id = ((await created.json()) as { id: string }).id; + if (tok === tokA) orgA = id; else orgB = id; + const active = await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + userA = await idOf(`w-a-${posture}@wall.test`); + userB = await idOf(`w-b-${posture}@wall.test`); + + // The operator authors the catalog, and grants inside each organization: + // A holds `manage_metadata` and CRUD on the catalog and assignment tables + // in org A only (the stock `organization_admin` denies those tables, so + // without this grant the data door would refuse A on CRUD alone); + // B's member holds the shared set in org B. + const rbac = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + for (const body of [ + { name: META_AUTHOR, label: 'Meta author', objects: {}, systemPermissions: ['manage_metadata'] }, + { name: SHARED, label: 'Shared', objects: {}, systemPermissions: [] }, + { name: RBAC_WRITER, label: 'Catalog table writer', systemPermissions: [], objects: { sys_permission_set: rbac, sys_position: rbac, sys_user_permission_set: rbac, sys_user_position: rbac } }, + ]) { + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${body.name}`, body); + expect(saved.status, await saved.clone().text()).toBe(200); + } + for (const name of [META_AUTHOR, RBAC_WRITER]) { + await ql.insert('sys_user_permission_set', { user_id: userA, permission_set: name, organization_id: orgA }, + { context: { ...SYS, tenantId: orgA } }); + } + await ql.insert('sys_user_permission_set', { user_id: userB, permission_set: SHARED, organization_id: orgB }, + { context: { ...SYS, tenantId: orgB } }); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: a real walled posture; A holds `manage_metadata` in org A and is not the platform operator', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + expect(orgA).toBeTruthy(); + expect(orgB).toBeTruthy(); + expect(orgA).not.toBe(orgB); + const a = await resolveUserAuthzGrants(ql as never, userA, { tenantId: orgA }); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const session = (await (await stack.apiAs(tokA, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).toBe(false); + expect(session.session?.activeOrganizationId).toBe(orgA); + }); + + it("org A's edit of a permission set org B's member holds is refused 403 FORBIDDEN, and B's resolution does not move", async () => { + const before = await bSystemPermissions(); + expect(before).not.toContain('manage_users'); + const res = await stack.apiAs(tokA, 'PUT', `/meta/permission/${SHARED}`, { + name: SHARED, label: 'Edited by organization A', objects: {}, systemPermissions: ['manage_users'], + }); + expect(await envelopeOf(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(await bSystemPermissions()).toEqual(before); + expect((await definitionOf('permission', SHARED))?.item?.label).toBe('Shared'); + }); + + it('org A cannot create a position, a permission set or a capability at `/meta`, nor delete one', async () => { + for (const [type, name, body] of [ + ['position', 'w_tenant_pos', { name: 'w_tenant_pos', label: 'Tenant position', permissionSets: [SHARED] }], + ['permission', 'w_tenant_set', { name: 'w_tenant_set', label: 'Tenant set', objects: {} }], + ['capability', 'w.tenant', { name: 'w.tenant', label: 'Tenant capability', scope: 'org' }], + ] as const) { + const res = await stack.apiAs(tokA, 'PUT', `/meta/${type}/${name}`, body); + expect(await envelopeOf(res), `${type} ${name}`).toEqual({ status: 403, code: 'FORBIDDEN' }); + } + expect((await definitionOf('position', 'w_tenant_pos'))?.item).toBeFalsy(); + expect((await definitionOf('permission', 'w_tenant_set'))?.item).toBeFalsy(); + + const del = await stack.apiAs(tokA, 'DELETE', `/meta/permission/${SHARED}`); + expect(await envelopeOf(del)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect((await definitionOf('permission', SHARED))?.item?.name).toBe(SHARED); + }); + + it('the data door refuses org A a catalog create 403 PERMISSION_DENIED, and nothing reaches the environment ledger', async () => { + const set = await stack.apiAs(tokA, 'POST', '/data/sys_permission_set', { name: 'w_dd_set', label: 'Data door set' }); + expect(await envelopeOf(set)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect((await definitionOf('permission', 'w_dd_set'))?.item).toBeFalsy(); + + const position = await stack.apiAs(tokA, 'POST', '/data/sys_position', { name: 'w_dd_pos', label: 'Data door position' }); + expect(await envelopeOf(position)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); + }); + + it('CONTROL: org A still assigns a catalog permission set to its own member', async () => { + const res = await stack.apiAs(tokA, 'POST', '/data/sys_user_permission_set', { user_id: userA, permission_set: SHARED }); + expect(res.status, await res.clone().text()).toBe(201); + }); + + it('CONTROL: the platform operator still saves the catalog', async () => { + const res = await stack.apiAs(operator, 'PUT', '/meta/position/w_operator_pos', { + name: 'w_operator_pos', label: 'Operator position', permissionSets: [SHARED], + }); + expect(res.status, await res.clone().text()).toBe(200); + expect((await definitionOf('position', 'w_operator_pos'))?.item?.name).toBe('w_operator_pos'); + }); + }); +} + +describe('[ADR-0131 D3] single: a `manage_metadata` holder still authors the catalog (control)', () => { + let stack: VerifyStack; + let ql: Engine; + let author: string; + + beforeAll(async () => { + stack = await bootStack(crmStack as never, {}); + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const operator = await stack.signIn(); + author = await stack.signUp('w-single-author@wall.test'); + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${META_AUTHOR}`, { + name: META_AUTHOR, label: 'Meta author', objects: {}, systemPermissions: ['manage_metadata'], + }); + expect(saved.status, await saved.clone().text()).toBe(200); + const [user] = await ql.find('sys_user', { where: { email: 'w-single-author@wall.test' }, limit: 1, context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: String(user.id), permission_set: META_AUTHOR }, { context: SYS }); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('saves a position: 200, unchanged by the walled refusal', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string }>('tenancy').catch(() => undefined); + expect(tenancy?.posture ?? 'single').toBe('single'); + const session = (await (await stack.apiAs(author, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).toBe(false); + const res = await stack.apiAs(author, 'PUT', '/meta/position/w_single_pos', { name: 'w_single_pos', label: 'Single position' }); + expect(res.status, await res.clone().text()).toBe(200); + }); +}); From 8d4d5ad9b311e462835118292bc2cd8c768e0646 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:53:43 +0000 Subject: [PATCH 03/11] chore(security): changeset and ADR-0131 anchors for the walled catalog refusal Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 25 +++++++++++++++++++ ...a-core__src__meta-write-capability.ts.json | 7 ++++++ ...ity__src__walled-catalog-data-door.ts.json | 7 ++++++ 3 files changed, 39 insertions(+) create mode 100644 .changeset/15204-walled-catalog-refusal.md create mode 100644 scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json create mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md new file mode 100644 index 00000000000..42018e93296 --- /dev/null +++ b/.changeset/15204-walled-catalog-refusal.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/plugin-security': minor +--- + +Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + +**Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + +**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + +- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` for `position`, `permission` or `capability`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. + +**Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + +**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. diff --git a/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json new file mode 100644 index 00000000000..467bdcac6a9 --- /dev/null +++ b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/metadata-core/src/meta-write-capability.ts", + "adrs": [ + "ADR-0131" + ], + "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131 D3's walled catalog refusal: under `group` / `isolated` a write of a position, permission set or capability is admitted only for `isSystem` or the platform operator rung, never for an organization-scoped `manage_metadata` holder, because one organization's write would change what every other organization resolves. Every `/meta` write door and the security plugin's data door ask it; `single` is inert." +} diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json new file mode 100644 index 00000000000..0f79c307ca6 --- /dev/null +++ b/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/plugins/plugin-security/src/walled-catalog-data-door.ts", + "adrs": [ + "ADR-0131" + ], + "invariant": "Under a walled posture (`group` / `isolated`) the security catalog — positions, permission sets, capabilities — is environment-level and every organization resolves it by name, so a data-door create, update or delete of a catalog row is the platform operator's alone (ADR-0131 D3: tenants are refused creation and may only assign). Assignment rows are never judged here; `single` is inert." +} From 70f4febfad453184d8ae70e516f7ca3df3c56aab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:11:59 +0000 Subject: [PATCH 04/11] test(runtime): pin the dispatcher PUT's walled catalog refusal (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../meta-walled-catalog-refusal.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts diff --git a/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts b/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts new file mode 100644 index 00000000000..fec22974c79 --- /dev/null +++ b/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] The dispatcher's `PUT /meta/:type/:name` asks the walled + * catalog refusal (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) + * right after its capability gate: under `isolated` / `group` a position, a + * permission set or a capability is saved only by the platform operator rung + * or the engine itself. `RestServer`'s four write doors ask the same predicate; + * the booted proof of both transports' shared behaviour, including the + * cross-organization read-back, is `wall-catalog-refusal.dogfood.test.ts`. + * + * Pinned here, on this transport: the refusal's envelope + * (`403 PERMISSION_DENIED`, this transport's spelling), that nothing reaches + * `saveMetaItem`, and the controls (the operator, `single`, no `tenancy` + * service, and a type outside the catalog). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +function boot(posture: 'single' | 'isolated' | 'group' | null) { + const saveMetaItem = vi.fn(async ({ type, name }: any) => ({ success: true, type, name })); + const getMetaItem = vi.fn(async () => null); + const protocol = { saveMetaItem, getMetaItem }; + const services: Record = { protocol }; + if (posture) services.tenancy = { posture }; + const resolve = (name: string): unknown => services[name] ?? null; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel as never), saveMetaItem }; +} + +const ctx = (executionContext: any): any => ({ request: {}, environmentId: 'platform', executionContext }); + +/** An organization-scoped `manage_metadata` holder: the capability gate admits it, and it is not the operator. */ +const TENANT_AUTHOR = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' }; +const OPERATOR = { userId: 'u_operator', systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }; + +const BODIES: Record> = { + position: { name: 'plant_lead', label: 'Plant lead' }, + permission: { name: 'plant_set', label: 'Plant set', objects: {} }, + capability: { name: 'plant.cap', label: 'Plant cap', scope: 'org' }, +}; + +describe('[ADR-0131 D3] dispatcher PUT /meta — the walled catalog refusal', () => { + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: a tenant's save of each catalog type is refused 403 PERMISSION_DENIED, and nothing is saved`, async () => { + for (const [type, body] of Object.entries(BODIES)) { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata(`/${type}/${body.name}`, ctx(TENANT_AUTHOR), 'PUT', body); + expect(res.response?.status, type).toBe(403); + expect(res.response?.body?.error?.code, type).toBe('PERMISSION_DENIED'); + expect(String(res.response?.body?.error?.message), type).toContain(`'${posture}' tenancy posture`); + expect(stack.saveMetaItem, type).not.toHaveBeenCalled(); + } + }); + + it(`${posture}: a plural URL spelling is judged as the catalog type it writes`, async () => { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata('/permissions/plant_set', ctx(TENANT_AUTHOR), 'PUT', BODIES.permission); + expect(res.response?.status).toBe(403); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it(`${posture}: CONTROL — the platform operator rung saves`, async () => { + const stack = boot(posture); + await stack.dispatcher.handleMetadata('/position/plant_lead', ctx(OPERATOR), 'PUT', BODIES.position); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it(`${posture}: CONTROL — a type outside the catalog is not judged`, async () => { + const stack = boot(posture); + await stack.dispatcher.handleMetadata('/view/plant_view', ctx(TENANT_AUTHOR), 'PUT', { name: 'plant_view', label: 'Plant view' }); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + } + + it('single: CONTROL — the same `manage_metadata` holder saves a position, unchanged', async () => { + const stack = boot('single'); + await stack.dispatcher.handleMetadata('/position/plant_lead', ctx(TENANT_AUTHOR), 'PUT', BODIES.position); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it('no `tenancy` service behaves like `single` (ADR-0093 D4/D5)', async () => { + const stack = boot(null); + await stack.dispatcher.handleMetadata('/position/plant_lead', ctx(TENANT_AUTHOR), 'PUT', BODIES.position); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it('the capability gate still answers first: a caller without `manage_metadata` gets its sentence, not the posture one', async () => { + const stack = boot('isolated'); + const res = await stack.dispatcher.handleMetadata('/position/plant_lead', ctx({ userId: 'u_x', systemPermissions: [] }), 'PUT', BODIES.position); + expect(res.response?.status).toBe(403); + expect(String(res.response?.body?.error?.message)).toContain('requires the `manage_metadata` capability'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); +}); From de89dd413be3bb0c4186738fc5562b0fe99faf9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:45:49 +0000 Subject: [PATCH 05/11] chore(spec,security): register the walled catalog refusal as an ADR-0087 semantic entry; census row 55b Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 5 ++- content/docs/permissions/system-context.mdx | 21 ++++----- .../src/walled-catalog-data-door.test.ts | 4 +- ...18.walled-catalog-tenant-writes-retired.ts | 43 +++++++++++++++++++ 4 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md index 42018e93296..9f40050b5d0 100644 --- a/.changeset/15204-walled-catalog-refusal.md +++ b/.changeset/15204-walled-catalog-refusal.md @@ -3,13 +3,14 @@ '@objectstack/rest': minor '@objectstack/runtime': minor '@objectstack/plugin-security': minor +'@objectstack/spec': minor --- Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them Clause-②: yes (narrowing) - + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). @@ -22,4 +23,4 @@ Clause-②: yes (narrowing) **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. -**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0b52f085e4d..edbef7d2a2a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **123 +because the flag is not one concept: it is a single boolean read at **127 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -142,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 123 sites**. +The largest single consumer — **17 of the 127 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -181,6 +181,7 @@ The largest single consumer — **17 of the 123 sites**. | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback) and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | @@ -285,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 123 read sites +- **Shipped semantics.** `isSystem` is a published contract with 127 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -357,18 +358,18 @@ still holds equal to the census on every pull request: | — in tests | 1013 | — | | — in non-test sources | 798 | — | | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | -| — parsed as a declaration | 28 | ✅ | +| — parsed as a declaration | 29 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 129 | ✅ | +| — parsed as a property **read** | 133 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 120 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **127** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 124 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | -| Files containing at least one elevation read | 57 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 105 | ✅ | +| Files containing at least one elevation read | 58 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 108 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were @@ -432,7 +433,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **57** +cannot say WHICH read inside a function it means, and **8** of the **58** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts index ee23104f024..003a885f3f6 100644 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts @@ -151,7 +151,7 @@ async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: fa const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; async function storedNames(engine: ObjectQL, object: string, name: string): Promise { - const rows = await engine.find(object, { where: { name }, context: { isSystem: true } } as any); + const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); return Array.isArray(rows) ? rows.length : 0; } @@ -176,7 +176,7 @@ for (const posture of ['isolated', 'group'] as const) { .toEqual(REFUSED); expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) .toEqual(REFUSED); - const [row] = await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } } as any) as any[]; + const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; expect(row?.label).toBe('Existing'); expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); }); diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts new file mode 100644 index 00000000000..78af500a9d9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (stage W of the C3 card) — a runtime-door narrowing with no +// authorable key: under a walled tenancy posture the security catalog's write +// doors admit only the platform operator. Registered because a caller that +// wrote catalog items before is refused now, and an operator must be told who +// keeps that authority and what a tenant does instead. +export const entry: SemanticMigration = { + id: 'walled-catalog-tenant-writes-retired', + // No backticks in `surface`: build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'on a group or isolated deployment, a write of a position, permission set or capability by a ' + + 'caller below the platform operator: PUT, DELETE, publish and rollback of /meta/position, ' + + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, and a ' + + 'data-door insert, update or delete on sys_position, sys_permission_set and sys_capability ' + + 'through @objectstack/plugin-security', + replacement: + 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + + '`OS_PLATFORM_OWNER_EMAIL` owner) defines the catalog, in Studio or through a managed package; ' + + 'an organization\'s administrators assign it to their own members through ' + + '`sys_user_position` and `sys_user_permission_set`, which are unchanged. A custom door that ' + + 'writes catalog items asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) after ' + + '`metaWriteCapabilityVerdict`', + reason: + 'ADR-0131 D3: on a shared-database multi-tenant deployment tenant admins cannot create or edit ' + + 'catalog items, and the operator defines the catalog for every tenant. The catalog is ' + + 'environment-level and every organization resolves it by name, so before this change a ' + + 'manage_metadata holder scoped to one organization could save over a permission set another ' + + 'organization\'s members hold and change what those members resolve.', + acceptanceCriteria: + 'On an isolated or group deployment, an organization owner holding manage_metadata inside ' + + 'their own organization saves PUT /api/v1/meta/permission/: the answer is 403 FORBIDDEN ' + + '(403 PERMISSION_DENIED on the runtime dispatcher), the message names the posture and the ' + + 'manage_metadata capability, and another organization\'s holder of that set resolves exactly ' + + 'what it resolved before. A data-door create on sys_position or sys_permission_set by the same ' + + 'caller answers 403 PERMISSION_DENIED and stores nothing. The platform operator saves the same ' + + 'item with 200, the same organization owner still assigns a catalog permission set to its own ' + + 'member with 201, and on a single deployment a manage_metadata holder saves catalog items ' + + 'exactly as before. Items saved before this change keep their bytes.', +}; From 26c70abf88d3fd928df59d61f25ad2e6ed74632a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:16:58 +0000 Subject: [PATCH 06/11] fix(runtime): the permission-set clone door asks the walled catalog refusal (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../test/wall-catalog-refusal.dogfood.test.ts | 18 ++++++++ .../src/domains/permission-set-clone.test.ts | 44 +++++++++++++++++++ .../src/domains/permission-set-clone.ts | 24 +++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts index 7df2a89ed89..2f38d83196e 100644 --- a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -24,6 +24,8 @@ // `403 FORBIDDEN`, and B's resolution does not move; // - walled: the data door's catalog creates answer `403 PERMISSION_DENIED`, // and nothing reaches the environment ledger; +// - walled: the permission-set clone door refuses org A a clone +// `403 PERMISSION_DENIED`, and nothing is saved; // - walled CONTROLS: org A still assigns a catalog permission set to its own // member, and the platform operator still saves the catalog; // - `single` CONTROL: a `manage_metadata` holder who is not the platform @@ -177,6 +179,22 @@ for (const posture of ['isolated', 'group'] as const) { expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); }); + it("the clone door refuses org A a clone of an environment set 403 PERMISSION_DENIED, and nothing is saved", async () => { + const res = await stack.apiAs(tokA, 'POST', `/security/permission-sets/${SHARED}/clone`, { + name: 'w_tenant_clone', label: 'Tenant clone', + }); + expect(await envelopeOf(res)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect((await definitionOf('permission', 'w_tenant_clone'))?.item).toBeFalsy(); + }); + + it('CONTROL: the platform operator still clones a catalog set', async () => { + const res = await stack.apiAs(operator, 'POST', `/security/permission-sets/${SHARED}/clone`, { + name: 'w_operator_clone', label: 'Operator clone', + }); + expect(res.status, await res.clone().text()).toBe(200); + expect((await definitionOf('permission', 'w_operator_clone'))?.item?.name).toBe('w_operator_clone'); + }); + it('CONTROL: org A still assigns a catalog permission set to its own member', async () => { const res = await stack.apiAs(tokA, 'POST', '/data/sys_user_permission_set', { user_id: userA, permission_set: SHARED }); expect(res.status, await res.clone().text()).toBe(201); diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index 90f43a4a3ba..40426c165aa 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -59,6 +59,8 @@ interface BootOptions { noProtocol?: boolean; saveError?: Error; catalogError?: Error; + /** The `tenancy` service's posture; absent ⇒ no `tenancy` service (the `single` shape). */ + posture?: 'single' | 'isolated' | 'group'; } function boot(opts: BootOptions = {}) { @@ -83,6 +85,7 @@ function boot(opts: BootOptions = {}) { }; const services: Record = { objectql: engine, data: engine }; if (!opts.noProtocol) services.protocol = protocol; + if (opts.posture) services.tenancy = { posture: opts.posture }; const resolve = (name: string): unknown => services[name] ?? null; const kernel = { getService: resolve, @@ -260,3 +263,44 @@ describe('the permission-set clone door — refusals', () => { expect(statusOf(res)).toBe(501); }); }); + +// [ADR-0131 D3] The walled catalog refusal: a clone creates an environment +// permission set, so under a wall it is the platform operator's. Same predicate +// and envelope as the dispatcher's `PUT /meta` (`meta-walled-catalog-refusal.test.ts`). +describe('the permission-set clone door — the walled catalog refusal (ADR-0131 D3)', () => { + const TENANT_HOLDER = () => ctx({ userId: 'u_tenant', tenantId: 'org_a', positions: [], systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' }); + const OPERATOR = () => ctx({ userId: 'u_operator', positions: [], systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }); + + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: a tenant manage_metadata holder is refused 403 PERMISSION_DENIED, and nothing is saved`, async () => { + const h = boot({ posture }); + const res = await clone(h, TENANT_HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(String((res as any)?.response?.body?.error?.message)).toContain(`'${posture}' tenancy posture`); + expect(h.saves).toHaveLength(0); + }); + + it(`${posture}: CONTROL — the platform operator rung clones`, async () => { + const h = boot({ posture }); + const res = await clone(h, OPERATOR(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(200); + expect(h.saves).toHaveLength(1); + }); + } + + it('single: CONTROL — the same tenant holder clones, unchanged', async () => { + const h = boot({ posture: 'single' }); + const res = await clone(h, TENANT_HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(200); + expect(h.saves).toHaveLength(1); + }); + + it('the capability gate still answers first under a wall', async () => { + const h = boot({ posture: 'isolated' }); + const res = await clone(h, NON_HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(403); + expect(String((res as any)?.response?.body?.error?.message)).toContain('`manage_metadata`'); + expect(h.saves).toHaveLength(0); + }); +}); diff --git a/packages/runtime/src/domains/permission-set-clone.ts b/packages/runtime/src/domains/permission-set-clone.ts index 7b7e9e24fbe..245d8297954 100644 --- a/packages/runtime/src/domains/permission-set-clone.ts +++ b/packages/runtime/src/domains/permission-set-clone.ts @@ -68,11 +68,18 @@ * stay with `manage_metadata` holders). ⛔ No new gate, and ⛔ not the ADR-0126 * §5 activation gate: a clone creates a new artifact and takes nothing away * from any tenant, exactly as the flow clone door reasons. + * + * Then, as at every catalog write door, the walled catalog refusal + * (`walledCatalogWriteVerdict`, ADR-0131 D3): under `isolated` / `group` the + * catalog is shared by every organization, so the clone is the platform + * operator's. That is D3's own refusal ("the server refuses anyway"), not a new + * gate; under `single` it is inert. */ import { METADATA_READ_DECORATIONS, MetadataProtectionFields } from '@objectstack/spec/kernel'; import { SnakeCaseIdentifierSchema } from '@objectstack/spec/shared'; -import { metaWriteCapabilityVerdict } from '@objectstack/metadata-core'; +import { metaWriteCapabilityVerdict, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +import { readEffectiveTenancyPosture } from './activation-gate.js'; import { securityCatalogReaderOf, type SecurityCatalogEntry } from '@objectstack/core'; import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { validationFailure } from '../validation-failure.js'; @@ -236,6 +243,21 @@ export async function handlePermissionSetCloneWrite( if (!verdict.allowed) { return { handled: true, response: deps.error(verdict.message, 403) }; } + // [ADR-0131 D3] The walled catalog refusal, the metadata door's own and + // asked right after its capability gate, exactly as the dispatcher's + // `PUT /meta` asks it: a clone creates an environment permission set, so + // under `isolated` / `group` only the platform operator rung (or the engine + // itself) makes one. Inert under `single`. Before any read, so a refused + // caller learns nothing about the source or the target name. + const walled = walledCatalogWriteVerdict({ + type: PERMISSION_METADATA_TYPE, + tenancyPosture: await readEffectiveTenancyPosture(deps, context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!walled.allowed) { + return { handled: true, response: deps.error(walled.message, 403) }; + } try { // ── 2. shape ──────────────────────────────────────────────────────── From 763c52bcc0268fafa8acbab426e3aa5beed6981f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:26:06 +0000 Subject: [PATCH 07/11] docs(permissions): census counts after the clone door's walled check; row 55b names the clone door Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e46e53751be..c4b9b4f9f98 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **128 +because the flag is not one concept: it is a single boolean read at **129 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -142,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 128 sites**. +The largest single consumer — **17 of the 129 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -181,7 +181,7 @@ The largest single consumer — **17 of the 128 sites**. | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback) and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | @@ -286,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 128 read sites +- **Shipped semantics.** `isSystem` is a published contract with 129 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -360,17 +360,17 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 29 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 134 | ✅ | +| — parsed as a property **read** | 135 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **128** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 125 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **129** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 126 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 59 | ✅ | | — the distinct symbols those reads live in — what this page anchors | 109 | ✅ | -| — of those files, the ones holding more than one read in one symbol | 8 | ✅ | +| — of those files, the ones holding more than one read in one symbol | 9 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count @@ -433,7 +433,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **59** +cannot say WHICH read inside a function it means, and **9** of the **59** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must From 5354b160163455c1558da61a18a66c905b4a310e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:26:23 +0000 Subject: [PATCH 08/11] chore(spec): the walled catalog refusal's changeset and ADR-0087 entry name the clone door Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 1 + .../semantic/18.walled-catalog-tenant-writes-retired.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md index 9f40050b5d0..62823b94c0c 100644 --- a/.changeset/15204-walled-catalog-refusal.md +++ b/.changeset/15204-walled-catalog-refusal.md @@ -19,6 +19,7 @@ Clause-②: yes (narrowing) **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` for `position`, `permission` or `capability`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts index 78af500a9d9..0f67b6f66b5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -14,7 +14,8 @@ export const entry: SemanticMigration = { surface: 'on a group or isolated deployment, a write of a position, permission set or capability by a ' + 'caller below the platform operator: PUT, DELETE, publish and rollback of /meta/position, ' - + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, and a ' + + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, the ' + + 'permission-set clone door (POST /security/permission-sets/:name/clone), and a ' + 'data-door insert, update or delete on sys_position, sys_permission_set and sys_capability ' + 'through @objectstack/plugin-security', replacement: From 2243159ea1a913d72590a77cd0bed2d69c35528b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:38:34 +0000 Subject: [PATCH 09/11] chore(spec): the walled catalog refusal's changeset and ADR-0087 surface: exact doors, capability's unchanged accept set, the D12 delegate Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 5 +++-- .../18.walled-catalog-tenant-writes-retired.ts | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md index 62823b94c0c..d22ed5c368b 100644 --- a/.changeset/15204-walled-catalog-refusal.md +++ b/.changeset/15204-walled-catalog-refusal.md @@ -18,9 +18,10 @@ Clause-②: yes (narrowing) **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): -- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` for `position`, `permission` or `capability`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. -- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. +- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts index 0f67b6f66b5..9452e6b4d95 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -13,11 +13,11 @@ export const entry: SemanticMigration = { // code span AND a table cell. surface: 'on a group or isolated deployment, a write of a position, permission set or capability by a ' - + 'caller below the platform operator: PUT, DELETE, publish and rollback of /meta/position, ' - + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, the ' - + 'permission-set clone door (POST /security/permission-sets/:name/clone), and a ' - + 'data-door insert, update or delete on sys_position, sys_permission_set and sys_capability ' - + 'through @objectstack/plugin-security', + + 'caller below the platform operator (an ADR-0090 D12 delegate whose scope authors environment ' + + 'sets included): PUT, DELETE, publish and rollback of /meta/position, /meta/permission and ' + + '/meta/capability on @objectstack/rest, and PUT on the runtime dispatcher; the permission-set ' + + 'clone door (POST /security/permission-sets/:name/clone); and a data-door insert, update or ' + + 'delete on sys_position, sys_permission_set and sys_capability through @objectstack/plugin-security', replacement: 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + '`OS_PLATFORM_OWNER_EMAIL` owner) defines the catalog, in Studio or through a managed package; ' From a337a2fa625a03a247b4a172a0cc8ee445db1469 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:55:10 +0000 Subject: [PATCH 10/11] docs(permissions,spec): the walled caveat on authorEnvironmentSets, the clone door and the Setup env-door write (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- content/docs/permissions/authorization.mdx | 4 +++- content/docs/permissions/delegated-administration.mdx | 2 +- content/docs/permissions/permission-sets.mdx | 4 +++- packages/spec/src/security/permission.zod.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 8d5d9470c32..e4cfcd9426c 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -242,7 +242,9 @@ one of two doors, each writing only what it owns: CRUD) is transparently redirected into an env-scope metadata save (`saveMetaItem`), and the data record is a **pure projection** the platform re-derives on every metadata mutation (awaited — no staleness window) and at - boot. Deleting an artifact-backed set through this door **resets** it to its + boot. Under a `group` or `isolated` tenancy posture that Setup write is the + platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the + redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its declared body rather than removing it; renaming through the data door is rejected (the name is the metadata identity — clone instead). Subject assignments remain plain config rows, unchanged. diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index ba00c335783..5fe22db80e1 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -58,7 +58,7 @@ Writes to the governed tables (`sys_user_position`, | Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ | | Subtree anchoring | Assignments a delegate creates must be anchored (`sys_user_position.business_unit_id`) inside their subtree, and the target user must sit inside it | | Allowlist, no self-escalation | Every set reached by the write — bound to the assigned position, or granted directly — must be on `assignablePermissionSets`. Granting an un-allowlisted set is refused **for anyone, including the delegate themselves** | -| Action flags | `manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree | +| Action flags | `manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree. Under a `group` or `isolated` tenancy posture `authorEnvironmentSets` admits nothing at the data door: the platform operator authors the security catalog there (ADR-0131 D3). It still applies under `single` | | Strict containment | Granting or authoring a set that itself carries an `adminScope` requires a held scope that **strictly contains** it — handing your own exact scope to a peer is refused (no lateral propagation) | | Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked | | Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically | diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index d40d7c0bdc2..173313b77d6 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -417,7 +417,9 @@ it was copied from, so changes to the source never reach the copy. - **The copy is held by nobody** until you add it to a position or assign it. - **Who may call it** is who may save a permission set through the metadata API: a holder of `manage_metadata`. Anyone else is refused `403` - `PERMISSION_DENIED`. + `PERMISSION_DENIED`. Under a `group` or `isolated` tenancy posture the clone + is the platform operator's alone (ADR-0131 D3): a `manage_metadata` holder + inside one organization is refused `403` `PERMISSION_DENIED` too. ### Declared ≠ enforced — diagnosing a frozen package set diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index a09d39f0898..fbc81c70005 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -691,7 +691,7 @@ export const AdminScopeSchema = lazySchema(() => strictObject( /** May create/delete `sys_position_permission_set` bindings whose blast radius lies within the boundary. */ manageBindings: z.boolean().default(false).describe('Manage position↔permission-set bindings within the subtree'), /** May author environment-owned permission sets (package-managed rows stay publish-only per ADR-0086). */ - authorEnvironmentSets: z.boolean().default(false).describe('Author environment-owned permission sets'), + authorEnvironmentSets: z.boolean().default(false).describe('Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3)'), /** * The anti-self-escalation core: permission-set NAMES the delegate may * assign/bind. Every set reached by a delegated write must be in this list; From 637f1269b2ccbf52ffe706a0389f43b16f75b860 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:01:32 +0000 Subject: [PATCH 11/11] docs(references): regenerate the permission reference for the authorEnvironmentSets describe Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- content/docs/references/security/permission.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 224659ec51f..e3895711ec6 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -32,7 +32,7 @@ const result = AdminScopeSchema.parse(data); | **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) | | **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree | | **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree | -| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets | +| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) | | **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out | @@ -183,7 +183,7 @@ const result = AdminScopeSchema.parse(data); | **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) | | **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree | | **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree | -| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets | +| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) | | **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out | ### Nested Shape: `PermissionSet.protection`