diff --git a/.changeset/15204-walled-catalog-package-verbs.md b/.changeset/15204-walled-catalog-package-verbs.md new file mode 100644 index 00000000000..80165b29f5f --- /dev/null +++ b/.changeset/15204-walled-catalog-package-verbs.md @@ -0,0 +1,34 @@ +--- +'@objectstack/runtime': minor +'@objectstack/spec': minor +--- + +Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's + +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"). + +**Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: +- published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; +- rolled the package back, and the member lost it; +- reverted the operator's commit, and the member lost it; +- copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; +- discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); +- adopted the environment's catalog rows into their own package. + +**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). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: + +- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. +- `POST /api/v1/packages/:id/revert`, when the package has such a draft. +- `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. +- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. +- `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. +- `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. +- `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. + +**Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. + +**The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 9e47201922e..81906924101 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -127,6 +127,19 @@ Uninstall a package. Under a `group` or `isolated` tenancy posture, a package holding positions or permission sets is the platform operator's to delete: any other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3). +The package's other write verbs follow the same rule under a wall, judged by +the items each one acts on: +- `POST /packages/:id/publish-drafts`, `POST /packages/:id/discard-drafts` and + `POST /packages/:id/revert`, by the package's pending drafts; +- `POST /packages/:id/rollback`, by the commits it would undo; +- `POST /packages/:id/commits/:commitId/revert`, by that commit's own items, + whatever package the URL names; +- `POST /packages/:id/duplicate`, by the source's published items it copies; +- `POST /packages/:id/adopt-orphans`, by the environment's orphaned rows. + +When those items include a position, a permission set or a capability, only the platform +operator may run the verb. + ### `PATCH /packages/:id/enable` Enable a disabled package. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 1a53465c142..091db906aea 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -352,7 +352,10 @@ Environment-authored sets and other packages' rows survive. Under a `group` or `isolated` tenancy posture, removing a package that carries positions or permission sets is the platform operator's (ADR-0131 D3): that delete, and an `install-local` install, upgrade or uninstall of such a package, refuse any -other caller `403` before anything changes. +other caller `403` before anything changes. So do the package verbs that +publish, discard, roll back, revert, duplicate or adopt catalog items +(`publish-drafts`, `discard-drafts`, `revert`, `rollback`, +`commits/:commitId/revert`, `duplicate`, `adopt-orphans`). ## One authoritative store — the record is a projection (ADR-0094) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 592ff0edfff..db116ec513d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -183,7 +183,7 @@ One of the largest consumers of the flag; | 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, runtime | 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, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` delete of a package holding stored positions or permission sets. 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`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageDelete` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | 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, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` write doors (delete, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`) when the items they act on include a position, a permission set or a 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`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | | 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); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. 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` | diff --git a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts new file mode 100644 index 00000000000..301f8fb2748 --- /dev/null +++ b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3 · #15204 W3] The walled catalog refusal at the remaining +// `/packages` write doors, on a REAL booted stack. +// +// What this harness measured before the refusal, under both walled postures +// (6117740670): organization A's owner holds `manage_metadata` inside +// organization A only (rung `TENANT_ADMIN`). The operator authored a permission +// set in a writable runtime package and published it; organization B's member +// holds that set. Then A: +// - `POST /packages/:id/publish-drafts` published the operator's pending +// draft adding `manage_users`: `200`, and B's member gained `manage_users`; +// - `POST /packages/:id/rollback` and `POST /packages/:id/commits/:c/revert` +// undid the operator's published change: `200`, and B's member lost it; +// - `POST /packages/:id/duplicate` copied the set, under the same name, into +// A's own package, and `GET /meta` then served A's copy; +// - `POST /packages/:id/revert` discarded the operator's pending draft +// (`POST /packages/:id/discard-drafts` is the same discard, by a second door); +// - `POST /packages/:id/adopt-orphans` moved the environment's catalog rows +// into A's package. +// +// Pinned here, under each walled posture: every one of those answers +// `403 PERMISSION_DENIED`, nothing moves, and B's resolution stays. The commit +// revert is judged by the commit's own items, whatever package the URL names. +// CONTROLS: A runs publish-drafts, discard-drafts, revert, rollback, commit +// revert and duplicate on its own catalog-free package as before. Not +// adopt-orphans: that verb reads the environment's orphans, not A's package, +// and this environment carries the operator's catalog orphan. The platform +// operator runs every door on the catalog-carrying package, adopt-orphans +// included. And under `single` (CONTROL): the same `manage_metadata` holder +// runs every door on the operator's package as before. +// +// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled +// posture. 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`, the message and the HTTP status. */ +async function envelopeOf(res: Response): Promise<{ status: number; code: unknown; message: string }> { + const body = (await res.clone().json().catch(() => ({}))) as { error?: { code?: unknown; message?: unknown } }; + return { status: res.status, code: body?.error?.code, message: String(body?.error?.message ?? '') }; +} + +const set = (name: string, systemPermissions: string[]) => ({ name, label: name, objects: {}, systemPermissions }); +const dashboard = (name: string, label: string) => ({ name, label, columns: 12, widgets: [] }); + +const META_AUTHOR = 'w3_meta_author'; +const OP_PKG = 'com.w3.operator'; +const OP_SET = 'w3_operator_set'; +const ENV_SET = 'w3_env_set'; + +interface Harness { + stack: VerifyStack; + ql: Engine; + operator: string; + tokA: string; + userA: string; + userB: string; + orgA?: string; + orgB?: string; + /** The operator's first publish of `OP_SET` (systemPermissions []). */ + c1: string; + /** The operator's second publish of `OP_SET` (adds `manage_users`). */ + c2: string; +} + +async function call(stack: VerifyStack, token: string, method: string, path: string, body?: unknown) { + return stack.apiAs(token, method, path, body); +} + +/** The commit ids of a package, newest first. */ +async function commitIds(stack: VerifyStack, token: string, pkg: string): Promise { + const res = await call(stack, token, 'GET', `/packages/${pkg}/commits`); + expect(res.status, await res.clone().text()).toBe(200); + const json = (await res.json()) as { data?: { commits?: Array<{ id: string }> } }; + return (json.data?.commits ?? []).map((c) => c.id); +} + +/** + * Boot, sign in the operator, A and B; under a wall each gets an organization. + * The operator creates `OP_PKG`, publishes `OP_SET` twice (c1 with no + * capability, c2 adding `manage_users`) and leaves a third draft pending + * (adding `manage_metadata`); B holds `OP_SET`, so B resolves `manage_users`. + * The operator also saves `ENV_SET` with no package (an orphan). A holds + * `manage_metadata` (inside org A under a wall). + */ +async function boot(posture: 'isolated' | 'group' | 'single'): Promise { + const walled = posture !== 'single'; + if (walled) process.env.OS_TENANCY_POSTURE = posture; + else delete process.env.OS_TENANCY_POSTURE; + const stack = await bootStack(crmStack as never, walled ? { multiTenant: 'posture-only' } : {}); + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const operator = await stack.signIn(); + const tokA = await stack.signUp(`w3-a-${posture}@wall.test`); + const tokB = await stack.signUp(`w3-b-${posture}@wall.test`); + const h: Harness = { stack, ql, operator, tokA, userA: '', userB: '', c1: '', c2: '' }; + if (walled) { + for (const [tok, slug] of [[tokA, `w3-a-${posture}`], [tokB, `w3-b-${posture}`]] as const) { + const created = await call(stack, 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) h.orgA = id; else h.orgB = id; + const active = await call(stack, tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + } + const idOf = async (email: string) => + String((await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]?.id); + h.userA = await idOf(`w3-a-${posture}@wall.test`); + h.userB = await idOf(`w3-b-${posture}@wall.test`); + + const ok = async (res: Response, status = 200) => expect(res.status, await res.clone().text()).toBe(status); + await ok(await call(stack, operator, 'POST', '/packages', { id: OP_PKG, name: 'w3 operator', version: '1.0.0', type: 'app' }), 201); + // META_AUTHOR lives in the operator's package, so the orphan adoption below + // reads ENV_SET alone as the environment's catalog orphan this file adds. + await ok(await call(stack, operator, 'PUT', `/meta/permission/${META_AUTHOR}?package=${OP_PKG}`, set(META_AUTHOR, ['manage_metadata']))); + await ok(await call(stack, operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, []))); + await ok(await call(stack, operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + h.c1 = (await commitIds(stack, operator, OP_PKG))[0]!; + await ok(await call(stack, operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users']))); + await ok(await call(stack, operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + h.c2 = (await commitIds(stack, operator, OP_PKG))[0]!; + await ok(await call(stack, operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users', 'manage_metadata']))); + await ok(await call(stack, operator, 'PUT', `/meta/permission/${ENV_SET}`, set(ENV_SET, []))); + + const grant = (user: string, permissionSet: string, org: string | undefined) => + ql.insert('sys_user_permission_set', { user_id: user, permission_set: permissionSet, ...(org ? { organization_id: org } : {}) }, + { context: { ...SYS, ...(org ? { tenantId: org } : {}) } }); + await grant(h.userA, META_AUTHOR, h.orgA); + await grant(h.userB, OP_SET, h.orgB); + return h; +} + +const grantsOf = (h: Harness, user: string, org: string | undefined) => + resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); + +const rowsOf = (h: Harness, where: Record) => h.ql.find('sys_metadata', { where, context: SYS }); + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3 · #15204 W3] ${posture}: the /packages write doors refuse a tenant the operator's catalog items`, () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + const bGrants = () => grantsOf(h, h.userB, h.orgB); + /** B's resolution, as one comparable value. */ + const bSnapshot = async () => { + const g = await bGrants(); + return JSON.stringify({ sys: [...g.systemPermissions].sort(), sets: [...g.permissions].sort() }); + }; + // Each pin below compares B's resolution across its OWN request, so a pin + // reads only its own door: an earlier pin's outcome cannot redden it. + const refused = async (res: Response) => { + const env = await envelopeOf(res); + expect({ status: env.status, code: env.code }, env.message).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(env.message).toContain(`'${posture}'`); + expect(env.message).toContain('ADR-0131 D3'); + }; + + beforeAll(async () => { h = await boot(posture); }, 180_000); + afterAll(async () => { + await h?.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, below the operator; B resolves `manage_users` through the operator\'s set, with a draft pending', async () => { + const tenancy = await h.stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + const a = await grantsOf(h, h.userA, h.orgA); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const b = await bGrants(); + expect(b.permissions).toContain(OP_SET); + expect(b.systemPermissions).toContain('manage_users'); + expect(b.systemPermissions).not.toContain('manage_metadata'); + expect(h.c1).not.toBe(h.c2); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + + it('publish-drafts: A publishing the operator\'s pending catalog draft is refused, and B does not gain `manage_metadata`', async () => { + const before = await bSnapshot(); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + expect(await bSnapshot()).toBe(before); + expect((await bGrants()).systemPermissions).not.toContain('manage_metadata'); + }); + + it('revert: A discarding the operator\'s pending catalog draft is refused, and the draft stays', async () => { + const drafted = await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, + set(OP_SET, ['manage_users', 'manage_metadata'])); + expect(drafted.status, await drafted.clone().text()).toBe(200); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/revert`, {})); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + + it('discard-drafts: A discarding the operator\'s pending catalog draft is refused, and the draft stays', async () => { + const drafted = await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, + set(OP_SET, ['manage_users', 'manage_metadata'])); + expect(drafted.status, await drafted.clone().text()).toBe(200); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + + it('rollback: A rolling the operator\'s package back past a catalog commit is refused, and B keeps `manage_users`', async () => { + const before = await bSnapshot(); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); + expect(await bSnapshot()).toBe(before); + }); + + it('commits/:commitId/revert: A reverting the operator\'s catalog commit is refused, and B keeps `manage_users`', async () => { + const before = await bSnapshot(); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/commits/${h.c2}/revert`, {})); + expect(await bSnapshot()).toBe(before); + }); + + it('commits/:commitId/revert: the commit is judged by its own items, whatever package the URL names', async () => { + const own = await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.url', name: 'url', version: '1.0.0', type: 'app' }); + expect(own.status, await own.clone().text()).toBe(201); + const before = await bSnapshot(); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/com.w3.a.url/commits/${h.c2}/revert`, {})); + expect(await bSnapshot()).toBe(before); + }); + + it('duplicate: A copying the operator\'s catalog-carrying package is refused, and nothing is copied', async () => { + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.dup' })); + expect(await rowsOf(h, { package_id: 'com.w3.dup' })).toHaveLength(0); + }); + + it('adopt-orphans: A adopting the environment\'s catalog orphans into its own package is refused, and the orphan stays unbound', async () => { + const own = await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.adopt', name: 'adopt', version: '1.0.0', type: 'app' }); + expect(own.status, await own.clone().text()).toBe(201); + await refused(await call(h.stack, h.tokA, 'POST', '/packages/com.w3.a.adopt/adopt-orphans', {})); + const env = await rowsOf(h, { type: 'permission', name: ENV_SET }); + expect(env.map((r) => r.package_id ?? null)).not.toContain('com.w3.a.adopt'); + }); + + it('CONTROL: A runs every door but adopt-orphans on its own catalog-free package as before', async () => { + const pkg = 'com.w3.a.free'; + const dash = 'w3_free_dash'; + const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); + expect((await call(h.stack, h.tokA, 'POST', '/packages', { id: pkg, name: 'free', version: '1.0.0', type: 'app' })).status).toBe(201); + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v1'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/publish-drafts`, {})); + const first = (await commitIds(h.stack, h.tokA, pkg))[0]!; + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v2'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/publish-drafts`, {})); + const second = (await commitIds(h.stack, h.tokA, pkg))[0]!; + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/commits/${second}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/rollback`, { commitId: first })); + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v3'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v4'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: pkg, state: 'draft' })).toHaveLength(0); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/duplicate`, { targetPackageId: 'com.w3.a.free.copy' })); + }); + + it('CONTROL: the platform operator runs every one of these doors on its catalog-carrying package', async () => { + const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.op.copy' })); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/revert`, {})); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_metadata']))); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(0); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/commits/${h.c2}/revert`, {})); + expect((await bGrants()).systemPermissions).not.toContain('manage_users'); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users']))); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + expect((await bGrants()).systemPermissions).toContain('manage_users'); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); + expect((await bGrants()).systemPermissions).not.toContain('manage_users'); + const own = await call(h.stack, h.operator, 'POST', '/packages', { id: 'com.w3.op.oporphans', name: 'adopt', version: '1.0.0', type: 'app' }); + expect(own.status, await own.clone().text()).toBe(201); + await ok(await call(h.stack, h.operator, 'POST', '/packages/com.w3.op.oporphans/adopt-orphans', {})); + }); + }); +} + +describe('[ADR-0131 D3 · #15204 W3] single (CONTROL): the refusal is inert', () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + + beforeAll(async () => { h = await boot('single'); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('a `manage_metadata` holder below the operator runs every one of these doors on the operator\'s package as before', async () => { + const a = await grantsOf(h, h.userA, undefined); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.dup' })); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/commits/${h.c1}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/revert`, {})); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_metadata']))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/discard-drafts`, {})); + expect((await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.adopt', name: 'adopt', version: '1.0.0', type: 'app' })).status).toBe(201); + await ok(await call(h.stack, h.tokA, 'POST', '/packages/com.w3.a.adopt/adopt-orphans', {})); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 4467b92274e..aad47d51d54 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -410,33 +410,45 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * 422 (and so this gate never becomes an existence oracle of its own). */ /** - * [#15204 W2] ADR-0131 D3 at `DELETE /packages/:id`: under a wall the security - * catalog is the platform operator's, so deleting a package that carries a - * position or a permission set is too. - * - * Measured before this refusal, under `isolated` and `group`: the operator - * created a writable runtime package and saved a permission set into it - * (`PUT /meta/permission/:name?package=`); another organization's member held - * that set; an organization's `manage_metadata` holder (rung `TENANT_ADMIN`) - * deleted the package, `200`, and the set left the catalog and the member's - * grants. The capability gate above checks no rung. - * - * The items are read where the delete reads them: `deletePackage` removes the - * `sys_metadata` rows bound to the package, and those rows are not held under - * the package in the running registry, so the stored rows are what is asked. - * The decision is {@link walledCatalogWriteVerdict}'s, never restated. It is - * asked FIRST, for the first catalog type: its answer does not vary across - * catalog types, so when it admits this caller (under `single`, with no - * `tenancy` service, for `isSystem` or for the `PLATFORM_ADMIN` rung) nothing - * is read and the delete behaves exactly as before. Only a refused caller's - * package is read, and a package without catalog rows is admitted. The answer - * is this domain's `403` (`PERMISSION_DENIED`). + * [#15204 W2 · W3] ADR-0131 D3 at the `/packages` write doors: under a wall the + * security catalog is the platform operator's, so a package write that + * creates, changes, restores or removes a position or a permission set is the + * operator's too. + * + * Measured before these refusals, under `isolated` and `group`. An + * organization's `manage_metadata` holder (rung `TENANT_ADMIN`) acted on a + * writable runtime package the operator had saved a permission set into, and + * another organization's member held that set: + * - `DELETE /packages/:id` answered `200`; the set left the catalog and the + * member's grants (W2); + * - `publish-drafts` published the operator's pending draft adding + * `manage_users`, and the member gained it; + * - `rollback` and `commits/:commitId/revert` undid the operator's published + * change, and the member lost it; + * - `duplicate` copied the set, under the same name, into the holder's own + * package, and `GET /meta` then served that copy; + * - `revert` discarded the operator's pending catalog draft; + * - `adopt-orphans` moved environment catalog rows, the operator's included, + * into the holder's own package. + * The capability gate before each of them checks no rung. + * + * Each door passes `readTypes`: the metadata types of the items its verb acts + * on, read where the verb reads them (stored `sys_metadata` rows, or a + * commit's items; the running registry holds neither under the package). The + * decision is {@link walledCatalogWriteVerdict}'s, never restated. It is asked + * FIRST, for the first catalog type: its answer does not vary across catalog + * types, so when it admits this caller (under `single`, with no `tenancy` + * service, for `isSystem` or for the `PLATFORM_ADMIN` rung) `readTypes` is + * never called and the door behaves exactly as before. A write that touches no + * catalog item is admitted. The answer is this domain's `403` + * (`PERMISSION_DENIED`). */ -async function refuseWalledCatalogPackageDelete( +async function refuseWalledCatalogPackageWrite( deps: DomainHandlerDeps, context: HttpProtocolContext, - qlService: Pick | null | undefined, - packageId: string, + /** The refused write, as the message names it, e.g. `Deleting package "x"`. */ + action: string, + readTypes: () => Promise>, ): Promise { const ec: any = context?.executionContext; const tenancyPosture = await readEffectiveTenancyPosture(deps, context); @@ -447,35 +459,68 @@ async function refuseWalledCatalogPackageDelete( callerPosture: ec?.posture, }); if (verdictFor(SECURITY_CATALOG_TYPES[0]!).allowed) return null; - if (typeof qlService?.find !== 'function') return null; - let rows: unknown; + let types: Iterable; try { - rows = await qlService.find('sys_metadata', { where: { package_id: packageId }, context: { isSystem: true } }); + types = await readTypes(); } catch (e) { return { handled: true, response: deps.errorFromThrown(e, 500) }; } - const storedTypes = new Set(); - for (const row of Array.isArray(rows) ? rows : []) { - const type = (row as { type?: unknown } | null)?.type; - if (typeof type === 'string' && isSecurityCatalogType(type)) storedTypes.add(type); - } - if (storedTypes.size === 0) return null; - for (const type of SECURITY_CATALOG_TYPES) { - if (!storedTypes.has(type)) continue; + for (const type of types) { + if (typeof type !== 'string' || !isSecurityCatalogType(type)) continue; const verdict = verdictFor(type); if (!verdict.allowed) { return { handled: true, - response: deps.error( - `Deleting package "${packageId}" is refused: the package carries security catalog items. ${verdict.message}`, - 403, - ), + response: deps.error(`${action} is refused: it touches security catalog items. ${verdict.message}`, 403), }; } } return null; } +/** + * [#15204 W3] The metadata types of the `sys_metadata` rows `where` selects, + * read with the system context the package verbs read them with. Empty when + * no data engine is reachable: the verb then has nothing to act on either. + */ +function storedRowTypes( + qlService: Pick | null | undefined, + where: Record, + keep: (row: Record) => boolean = () => true, +): () => Promise { + return async () => { + if (typeof qlService?.find !== 'function') return []; + const rows = await qlService.find('sys_metadata', { where, context: { isSystem: true } }); + return (Array.isArray(rows) ? rows : []) + .filter((row) => row && typeof row === 'object' && keep(row as Record)) + .map((row) => (row as { type?: unknown }).type); + }; +} + +/** + * [#15204 W3] The `sys_metadata_commit` row `commitId` names, or `undefined`. + * Read directly because `revertCommit` and `rollbackToPackageCommit` take the + * commit id alone: the URL's package segment is not what they act on. + */ +async function readCommitRow( + qlService: Pick | null | undefined, + commitId: string, +): Promise | undefined> { + if (typeof qlService?.find !== 'function') return undefined; + const rows = await qlService.find('sys_metadata_commit', { where: { id: commitId }, context: { isSystem: true } }); + const row = Array.isArray(rows) ? rows[0] : undefined; + return row && typeof row === 'object' ? (row as Record) : undefined; +} + +/** [#15204 W3] The metadata types a commit's `items` name (stored as JSON text, or already parsed). */ +function commitItemTypes(items: unknown): unknown[] { + let list: unknown = items; + if (typeof list === 'string') { + try { list = JSON.parse(list); } catch { return []; } + } + return Array.isArray(list) ? list.map((item) => (item as { type?: unknown } | null)?.type) : []; +} + function requireWritablePackage( deps: DomainHandlerDeps, engine: unknown, @@ -1496,6 +1541,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'publish-drafts' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: publishing a catalog draft is the + // operator's under a wall. The drafts this verb promotes. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Publishing the drafts of package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'draft' }), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.publishPackageDrafts === 'function') { try { @@ -1814,6 +1866,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'discard-drafts' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: discarding a catalog draft is the + // operator's under a wall. The drafts this verb discards. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Discarding the drafts of package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'draft' }), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.discardPackageDrafts === 'function') { try { @@ -1855,6 +1914,17 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 4 && parts[1] === 'commits' && parts[3] === 'revert' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const commitId = decodeURIComponent(parts[2]); + // [#15204 W3] ADR-0131 D3: reverting a commit that touched a + // catalog item is the operator's under a wall. Judged by the + // commit's own items: `revertCommit` takes the commit id alone, so + // the URL's package segment names nothing it acts on. A commit + // that does not exist reads as touching nothing; the verb answers + // its own 404. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Reverting commit "${commitId}"`, + async () => commitItemTypes((await readCommitRow(qlService, commitId))?.items), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.revertCommit === 'function') { try { @@ -1879,6 +1949,30 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (!body?.commitId) { return { handled: true, response: deps.error('Body { commitId } is required', 400) }; } + // [#15204 W3] ADR-0131 D3: a rollback reverts every `apply` + // commit of the target's package newer than the target, so it + // is the operator's under a wall when any of them touched a + // catalog item. Read as `rollbackToPackageCommit` reads them: + // the target row, then that package's commits. Every `apply` + // commit at or after the target's instant other than the + // target itself is read, a superset of the reverted ones. + const targetCommitId = String(body.commitId); + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Rolling back to commit "${targetCommitId}"`, + async () => { + const target = await readCommitRow(qlService, targetCommitId); + if (!target || typeof protocol.listCommits !== 'function') return []; + const commits: any[] = await protocol.listCommits({ packageId: target.package_id }); + const at = Date.parse(String( + commits.find((c) => c?.id === targetCommitId)?.createdAt ?? target.created_at, + )); + return commits + .filter((c) => c?.id !== targetCommitId && c?.operation === 'apply' + && !(Date.parse(String(c?.createdAt)) < at)) + .flatMap((c) => commitItemTypes(c?.items)); + }, + ); + if (walled) return walled; try { const result = await protocol.rollbackToPackageCommit({ commitId: String(body.commitId), @@ -1896,6 +1990,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: discarding a catalog draft is the + // operator's under a wall. The drafts this verb discards. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Reverting package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'draft' }), + ); + if (walled) return walled; // [#22090] A package's members live in one of two places, and each // is reverted by the service that holds it. A Studio-authored // package is `sys_metadata` rows bound by `package_id` — the rows @@ -1952,6 +2053,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: rebinding environment catalog rows into + // a package is the operator's under a wall. The orphans exactly as + // `reassignOrphanedMetadata` selects them: organization-less rows + // with no package (null, empty, or the `sys_metadata` sentinel). + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Adopting orphaned metadata into package "${id}"`, + storedRowTypes(qlService, { organization_id: null }, + (row) => row.package_id == null || row.package_id === '' || row.package_id === 'sys_metadata'), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (!protocol || typeof protocol.reassignOrphanedMetadata !== 'function') { return { handled: true, response: deps.error('Orphan adoption not supported', 501) }; @@ -1994,6 +2105,15 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // the target package record (`installPackage`) ahead of its copy // loop, so a refusal any later still leaves the empty shell behind. const notABase = requireDuplicableSource(deps, qlService, id); if (notABase) return notABase; + // [#15204 W3] ADR-0131 D3: a duplicate copies the source's items + // into a new package, and a copied catalog item is a catalog + // creation (its name is not re-namespaced), the operator's under + // a wall. The rows `duplicatePackage` copies: active, environment-wide. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Duplicating package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'active', organization_id: null }), + ); + if (walled) return walled; try { const result = await protocol.duplicatePackage({ sourcePackageId: id, @@ -2110,7 +2230,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // organization's holder passes it. [#15204 W2] What makes the delete // the operator's under a wall is the catalog refusal below (ADR-0131 // D3), for a package that carries a position or a permission set. - const walled = await refuseWalledCatalogPackageDelete(deps, _context, qlService, id); + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Deleting package "${id}"`, + storedRowTypes(qlService, { package_id: id }), + ); if (walled) return walled; const protocol = await resolveProtocol(deps, _context); const persists = Boolean(protocol && typeof protocol.deletePackage === 'function'); diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts new file mode 100644 index 00000000000..27e3838036b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (stage W3 of the C3 card) — a runtime-door narrowing with no +// authorable key: under a walled tenancy posture the `/packages` verbs that +// publish, discard, roll back, revert, duplicate or adopt security catalog +// items are the platform operator's. Registered because a caller that ran them +// before is refused now, and an operator must be told who keeps that authority. +export const entry: SemanticMigration = { + id: 'walled-catalog-package-verbs-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, by a caller below the platform operator, on the runtime ' + + 'dispatcher: POST /api/v1/packages/:id/publish-drafts, /discard-drafts and /revert when the package has a pending ' + + 'position, permission-set or capability draft; /rollback when a commit it would undo touched one; ' + + '/commits/:commitId/revert when that commit touched one, whatever package the URL names; ' + + '/duplicate when the published rows it copies hold one; and /adopt-orphans when the orphaned rows include one', + replacement: + 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + + '`OS_PLATFORM_OWNER_EMAIL` owner) runs those verbs on catalog-carrying packages; a package whose ' + + 'drafts, commits or rows carry no catalog item is handled exactly as before. A custom package ' + + 'door asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) for the catalog types its ' + + 'verb acts on, after its capability gate', + 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. A package\'s catalog ' + + 'items are environment-wide, so before this change a manage_metadata holder scoped to one ' + + 'organization could publish an operator\'s pending catalog draft, roll back or revert the ' + + 'operator\'s published catalog change, discard the operator\'s draft, copy the operator\'s ' + + 'catalog items into a package of its own, or adopt the environment\'s catalog rows, and so ' + + 'change what another organization\'s members resolve.', + acceptanceCriteria: + 'On an isolated or group deployment, the operator publishes a permission set in a writable ' + + 'package twice (the second adding manage_users) and leaves a third draft pending; another ' + + 'organization\'s member holds the set. An organization owner holding manage_metadata inside ' + + 'their own organization then calls publish-drafts, discard-drafts, revert, rollback to the first commit, ' + + 'commits//revert (also under a URL naming their own package), duplicate and, from a ' + + 'package of their own, adopt-orphans: each answers 403 PERMISSION_DENIED naming the posture, ' + + 'the draft stays pending, nothing is copied or rebound, and the member resolves exactly what it ' + + 'resolved before. The same owner runs every verb but adopt-orphans (which reads the ' + + 'environment\'s orphans, not the package) on a package of their own whose items carry no ' + + 'catalog item with 200; the platform operator runs every verb on the catalog-carrying package ' + + 'with 200; and on a single deployment a manage_metadata holder runs them exactly as before.', +};