diff --git a/.changeset/23231-walled-environment-metadata-writes.md b/.changeset/23231-walled-environment-metadata-writes.md new file mode 100644 index 00000000000..0910bdc6fa1 --- /dev/null +++ b/.changeset/23231-walled-environment-metadata-writes.md @@ -0,0 +1,31 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/cloud-connection': minor +'@objectstack/spec': minor +--- + +fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. + +**Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. + +**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`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). +- Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. +- `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. +- `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. +- `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. +- Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. +- The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. + +**Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. + +**The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 81906924101..44b0035c9e9 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -57,6 +57,15 @@ Get a specific metadata item with optional ETag caching. Create or update a metadata item. +Saving requires the `manage_metadata` capability. Under a `group` or `isolated` +tenancy posture it also requires the platform operator, for every type: the +environment's metadata is shared by every organization (ADR-0131 D12 items 7–8, +D11), so a `manage_metadata` holder inside one organization is refused +`403 FORBIDDEN` naming the posture. The same holds for `DELETE` (reset), +`POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback`, and for +`POST /meta/_migrate-stored`, the install-wide rewrite of the stored metadata, +preview included. + Creates are gated per type: `MetadataTypeRegistryEntry.allowRuntimeCreate` decides whether a **brand-new** name may be minted under that type at all, separately from whether an item that already exists may be overlaid (`allowEnvironmentOverlay`). A type that @@ -123,22 +132,17 @@ Get package details. ### `DELETE /packages/:id` -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. +Uninstall a package. + +Under a `group` or `isolated` tenancy posture every `/packages` write is the +platform operator's (ADR-0131 D12 items 7–8, D11): install (`POST /packages`), +the manifest `PATCH`, enable, disable, publish, `publish-drafts`, +`discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, +`adopt-orphans` and this delete. Any other caller, a `manage_metadata` holder +inside one organization included, is refused `403 PERMISSION_DENIED` naming the +posture, before anything is read or written, whatever the package carries. +Reads are unchanged. Under `single` every `manage_metadata` holder writes as +before. ### `PATCH /packages/:id/enable` diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index c9bac0b51bf..fe893ba59e2 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -37,6 +37,7 @@ fallback **input** to posture resolution. See | Enterprise `@objectstack/organizations` | not needed | **required** | **required** | | Default organization | one bootstrapped "Default Organization" | owned by the enterprise runtime | owned by the enterprise runtime | | Org-management UI (create / switch org) | hidden | shown | shown | +| Who writes the environment's metadata | any `manage_metadata` holder | the platform operator only | the platform operator only | Only the wall's **predicate** differs between `group` and `isolated`. Everything about how it composes is identical: Layer 0 is AND-composed ahead of, and @@ -46,6 +47,33 @@ W1/W2, ADR-0105 D2/D4). A walled posture with no resolvable scope — no active organization under `isolated`, an empty membership set under `group` — fails closed to zero rows rather than falling through. +### Under a wall, the environment's metadata is the platform operator's + +A walled deployment is one environment with one schema and one catalog +(ADR-0131 D12 items 7–8, D11). Its metadata (objects, fields, views, flows, +apps, dashboards, translations, packages, positions, permission sets, +capabilities, every type) carries no organization, so a write lands for every +organization at once. Under `group` and `isolated` only the platform operator +(the `PLATFORM_ADMIN` rung: an address declared in `OS_PLATFORM_OWNER_EMAIL`) +and the engine's own writes (seeders, installers, migrations) write it. Holding +`manage_metadata` inside an organization does not admit a write: the `/meta` +write doors (save, reset, publish, rollback), the install-wide stored rewrite +(`POST /meta/_migrate-stored`), every `/packages` write, +`install-local` install, upgrade and uninstall, and the `/automation` +definition writes refuse such a caller `403`, naming the posture. + +- **Headquarters IT** authors the environment's metadata as the platform + operator: declare its administrators in `OS_PLATFORM_OWNER_EMAIL`. A + `manage_metadata` grant scoped to an organization authors nothing under a wall. +- **An organization's administrators assign**: they place their own members in + the catalog's positions and permission sets, and manage their organization's + data. They define nothing. +- **An organization that needs metadata of its own** is given an environment of + its own. + +Under `single` the organization is the environment, and every `manage_metadata` +holder writes as before. + `single` is not "multi-org with the walls left standing": Layer 0 contributes no filter at all, and the platform's own tenant-scoped RLS policies are stripped. Policies **you** authored that mention `current_user.organization_id` are diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index 468f8b5cb93..9a3af4ea755 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -245,9 +245,10 @@ result is an unreferenced grant target sitting in the live authorization namespace. `job` and `agent` carry the same pair for the same reason. Under a `group` or `isolated` tenancy posture, a caller below the platform -operator reads a different refusal, with the same `403` status: the security -catalog refusal (ADR-0131 D3), whose message names the posture, with the code -`FORBIDDEN` on the REST door and `PERMISSION_DENIED` on the runtime dispatcher. +operator reads a different refusal, with the same `403` status: the walled +refusal every environment-metadata write takes there (ADR-0131 D3, D12 items +7–8), whose message names the posture, with the code `FORBIDDEN` on the REST +door and `PERMISSION_DENIED` on the runtime dispatcher. It runs before the code-only refusal, so the block above is the answer the platform operator reads, and every caller under `single`. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 795ea1d9cc5..d2095bf90a6 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -356,13 +356,10 @@ same request (no ghost grants); the uninstall response reports the revocation under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers the route, and it nests the protocol's uninstall report under `persisted`). 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. 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`). +`isolated` tenancy posture every package write is the platform operator's +(ADR-0131 D3, D12 items 7–8): the delete, an `install-local` install, upgrade +or uninstall, and every other `/packages` write verb refuse any other caller +`403` before anything changes, whatever the package carries. ## One authoritative store — the definition (ADR-0094 D1, ADR-0131 D3) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 79aae3c1e90..9d7206f2d91 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -184,7 +184,7 @@ One of the largest consumers of the flag; | 54 | 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` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 56 | 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` 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` | +| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, and at the data door on `sys_position`, `sys_permission_set` and `sys_capability` while those objects are registered. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata 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`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | | 57 | 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` | | 58 | 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` | | 59 | 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/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index edf1eed85c2..8f4f85bf772 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -1608,7 +1608,7 @@ ], "acceptance": [ { - "clause": "AUTHORITY RUNS FIRST and a refused caller learns nothing: the non-admin's mis-bodied undeclared-action probe answers 403 PERMISSION_DENIED with the manage_metadata sentence ('switching a shipped artifact off is functionally equivalent to deleting it', #10243) — NOT the 400 the body deserves and NOT the 404 the lookup would give, so neither the body contract nor the action namespace is disclosed. The refusal recommends NO clone (action-clone is deliberately unchartered — ACTION_ACTIVATION_SUBJECT's remedy names the operator and authoring a sibling action)", + "clause": "AUTHORITY RUNS FIRST and a refused caller learns nothing: the non-admin's mis-bodied undeclared-action probe answers 403 PERMISSION_DENIED with the manage_metadata sentence ('switching a shipped artifact off is functionally equivalent to deleting it', #10243) — NOT the 400 the body deserves and NOT the 404 the lookup would give, so neither the body contract nor the action namespace is disclosed. The refusal recommends NO clone (action-clone is deliberately unchartered — ACTION_ACTIVATION_SUBJECT's remedy names the operator alone: under a wall, where this refusal is answered, authoring a sibling action is the operator's too, ADR-0131 D12 items 7–8)", "oracle": "api", "verify": "step-2 non-admin trace: 403 + code PERMISSION_DENIED + the capability-naming message with no clone recommendation; the anonymous twin answers the 401 UNAUTHENTICATED baseline (the ADR-0056 floor sits ahead of even this)", "evidence": "both traces" diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 42622829a2f..9951d96475d 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -159,19 +159,10 @@ import { protocolIncompatibleAnswer, ProtocolIncompatibleError, type ProtocolIncompatibleDiagnostic, - // [#15204 W2] ADR-0131 D3's one predicate, asked here per declared catalog - // type: the same sentence, the same rung, as every catalog write door. + // [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate: the + // same sentence, the same rung, as every environment-metadata write door. walledCatalogWriteVerdict, - SECURITY_CATALOG_TYPES, } from '@objectstack/metadata-core'; -// [#15204 W2] The engine's own reading of what a manifest declares, and of -// what the running registry holds under a package, so this door judges the -// declarations the engine registers and not a second reading of them. -import { - declaredSecurityCatalogNames, - findPackageHeldSecurityCatalogNames, - type DeclaredSecurityCatalogName, -} from '@objectstack/objectql'; import { resolveCloudUrl } from './cloud-url.js'; import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; import { join } from 'node:path'; @@ -1319,16 +1310,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const manifestId = declaredId.data; if (inlineManifest) packageId = manifestId; - // [#15204 W2] ADR-0131 D3: under a wall, a package whose incoming or - // installed version carries a catalog item is the operator's to - // install, upgrade or replace. Asked before any later step of this - // install reads or writes anything. + // [ADR-0131 D12 items 7–8] Under a wall, installing, upgrading or + // replacing a package is the operator's. Asked before any later step + // of this install reads or writes anything. { - const installed = this.ledger.read(manifestId).entry?.manifest; - const walled = this.refuseWalledCatalogPackage( - c, ctx, 'Installing', manifestId, admission, - installed === undefined ? [manifest] : [manifest, installed], - ); + const walled = this.refuseWalledCatalogPackage(c, ctx, 'Installing', manifestId, admission); if (walled) return walled; } @@ -1925,14 +1911,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!this.ledger.has(manifestId)) { return c.json({ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: `No marketplace install for ${manifestId}.` } }, 404); } - // [#15204 W2] ADR-0131 D3: under a wall, uninstalling a package that - // carries a catalog item is the operator's: it removes those items - // from every organization's catalog. Asked before the ledger is touched. + // [ADR-0131 D12 items 7–8] Under a wall, uninstalling a package is the + // operator's: it removes the package's metadata from every + // organization. Asked before the ledger is touched. { - const walled = this.refuseWalledCatalogPackage( - c, ctx, 'Uninstalling', manifestId, admission, - [this.ledger.read(manifestId).entry?.manifest], - ); + const walled = this.refuseWalledCatalogPackage(c, ctx, 'Uninstalling', manifestId, admission); if (walled) return walled; } try { @@ -3460,31 +3443,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; /** - * [#15204 W2] ADR-0131 D3 at the package door: under a wall the security - * catalog is the platform operator's, and a package that declares a - * position, a permission set or a capability is the operator's instrument. - * - * Measured on `main` before this refusal: an organization's - * `manage_metadata` holder (rung `TENANT_ADMIN`) upgraded an - * operator-installed package at a new version, adding `manage_users` to a - * permission set another organization's member held, and that member - * resolved `manage_users` next; the same holder's uninstall took the set - * away from them. `refuseSecurityCatalogNameConflicts` refuses only a - * SHADOW of a name another holder has, never a write by the package that - * holds it. - * - * So the WHOLE package is refused, before any write, when any of - * `manifests` (the incoming version, the installed one, or both) declares - * a catalog item, or the running registry holds one under `manifestId`. - * A partial install is not well-formed: a package is registered whole. - * 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, or for the `PLATFORM_ADMIN` rung) nothing is - * read and the door behaves exactly as before. Only a refused caller's - * package is read, and the refusal names the first type it declares. A - * package with no catalog item is admitted. The answer is the sibling - * refusals' envelope, `403 FORBIDDEN`. + * [ADR-0131 D3 · D12 items 7–8 · #23231] The walled refusal at the package + * door: under a wall every organization shares the environment's metadata, + * and installing, upgrading or uninstalling a package registers or removes + * that metadata for every organization, so it is the platform operator's. + * + * Measured before this refusal (ruling A on #23231, record 6145019463), + * under `isolated` and `group`: an organization's `manage_metadata` holder + * (rung `TENANT_ADMIN`) installed a package carrying an object, upgraded + * an operator-installed one and uninstalled it, each `200`, and another + * organization's member read the installed object + * (`wall-environment-metadata-refusal.dogfood.test.ts`). W2 had refused + * only a package that declares a position, a permission set or a + * capability (ADR-0131 D3); that reading of the manifests is gone with the + * special case. + * + * The decision is {@link walledCatalogWriteVerdict}'s, never restated: + * admitted under `single`, with no `tenancy` service and for the + * `PLATFORM_ADMIN` rung, so the door then behaves exactly as before; + * refused for everyone else under a wall, before any later step reads or + * writes anything. The answer is the sibling refusals' envelope, + * `403 FORBIDDEN`. */ private refuseWalledCatalogPackage = ( c: any, @@ -3492,60 +3471,26 @@ export class MarketplaceInstallLocalPlugin implements Plugin { action: string, manifestId: string, admission: InstallAdmission, - manifests: readonly unknown[], ): Response | null => { - const verdictFor = (type: string) => walledCatalogWriteVerdict({ - type, + const verdict = walledCatalogWriteVerdict({ + type: 'package', tenancyPosture: admission.tenancyPosture, callerPosture: admission.callerPosture, }); - if (verdictFor(SECURITY_CATALOG_TYPES[0]!).allowed) return null; - const declared = this.catalogItemsOf(ctx, manifestId, manifests); - if (declared.length === 0) return null; - for (const type of SECURITY_CATALOG_TYPES) { - if (!declared.some((item) => item.type === type)) continue; - const verdict = verdictFor(type); - if (verdict.allowed) continue; - ctx.logger?.warn?.( - `[MarketplaceInstallLocal] refused ${action} ${manifestId} for ${admission.userId}: ` - + `the package carries security catalog items under the '${admission.tenancyPosture}' posture (ADR-0131 D3)`, - ); - return c.json({ - success: false, - error: { - code: 'FORBIDDEN', - message: `${action} "${manifestId}" is refused: the package carries security catalog items. ${verdict.message}`, - details: { manifestId, catalogItems: declared }, - }, - }, 403); - } - return null; - }; - - /** - * [#15204 W2] Every catalog item `manifests` declare, plus every position - * and permission-set name the running registry holds under `manifestId` - * (a package the registry holds outside this door's ledger, such as a - * writable runtime package an operator authored items into). Read-only. - */ - private catalogItemsOf = ( - ctx: PluginContext, - manifestId: string, - manifests: readonly unknown[], - ): DeclaredSecurityCatalogName[] => { - const out = new Map(); - const add = (item: DeclaredSecurityCatalogName) => out.set(`${item.type}:${item.name}`, item); - for (const manifest of manifests) for (const item of declaredSecurityCatalogNames(manifest)) add(item); - let registry: Parameters[0] | undefined; - try { - registry = (ctx.getService('objectql') as { registry?: typeof registry } | undefined)?.registry; - } catch { /* no data engine: the manifests are the whole reading */ } - if (registry) { - for (const held of findPackageHeldSecurityCatalogNames(registry)) { - if (held.packageIds.includes(manifestId)) add({ type: held.catalogType, name: held.name }); - } - } - return [...out.values()]; + if (verdict.allowed) return null; + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] refused ${action} ${manifestId} for ${admission.userId}: ` + + `the environment's metadata is the platform operator's under the '${admission.tenancyPosture}' posture ` + + '(ADR-0131 D12 items 7–8)', + ); + return c.json({ + success: false, + error: { + code: 'FORBIDDEN', + message: `${action} "${manifestId}" is refused. ${verdict.message}`, + details: { manifestId }, + }, + }, 403); }; /** diff --git a/packages/metadata-core/src/meta-write-capability.test.ts b/packages/metadata-core/src/meta-write-capability.test.ts index 6ff950d546e..3825e70e770 100644 --- a/packages/metadata-core/src/meta-write-capability.test.ts +++ b/packages/metadata-core/src/meta-write-capability.test.ts @@ -98,64 +98,84 @@ describe('metaWriteCapabilityVerdict — who is admitted', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// [ADR-0131 D3] The walled catalog refusal +// [ADR-0131 D3 · D12 items 7–8 · D11] The walled environment-metadata refusal // ───────────────────────────────────────────────────────────────────────────── -describe('[ADR-0131 D3] walledCatalogWriteVerdict — under a wall the security catalog is the platform operator\'s', () => { +describe('[ADR-0131 D12 items 7–8] walledCatalogWriteVerdict — under a wall the environment\'s metadata is the platform operator\'s', () => { const TENANT = { isSystem: false, callerPosture: 'TENANT_ADMIN' }; + /** Catalog and non-catalog types alike, plus the package doors' subject. */ + const TYPES = [...SECURITY_CATALOG_TYPES, 'object', 'view', 'flow', 'app', 'dashboard', 'translation', 'package', 'sys_metadata']; - it('refuses a non-operator write of each catalog type under `isolated` and `group`', () => { + it('refuses a non-operator write of every type under `isolated` and `group`', () => { for (const tenancyPosture of ['isolated', 'group'] as const) { - for (const type of SECURITY_CATALOG_TYPES) { + for (const type of 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 MEMBER-rung holder is refused as a TENANT_ADMIN-rung one is: the rung decides, not what is held', () => { + for (const type of ['object', 'permission']) { + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'group', callerPosture: 'MEMBER' }).allowed, type).toBe(false); + } }); - 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('the sentence names the written type, the posture and the ruling, and nothing about the caller', () => { + const subjects: Record = { + permission: 'a permission set', + position: 'a position', + capability: 'a capability', + package: 'a package', + sys_metadata: "the environment's stored metadata", + view: "a 'view' metadata item", + flow: "a 'flow' metadata item", + }; + for (const [type, subject] of Object.entries(subjects)) { + const v = walledCatalogWriteVerdict({ type, tenancyPosture: 'group', ...TENANT }); + expect(v.allowed).toBe(false); + const message = (v as { message: string }).message; + expect(message.split(': ')[0], type).toBe(`Creating, changing or deleting ${subject} requires the platform operator`); + expect(message, type).toContain("'group' tenancy posture"); + expect(message, type).toContain('ADR-0131 D3, D12 items 7–8, D11'); + expect(message, type).toContain('`manage_metadata`'); + expect(message, type).toContain('an environment of its own'); + expect(message, type).not.toContain('TENANT_ADMIN'); } }); - 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 URL spelling the protocol folds is named as the type it writes', () => { + for (const [type, subject] of [['permissions', 'a permission set'], ['views', "a 'view' metadata item"]] as const) { + const v = walledCatalogWriteVerdict({ type, tenancyPosture: 'isolated', ...TENANT }); + expect(v.allowed, type).toBe(false); + expect((v as { message: string }).message.startsWith(`Creating, changing or deleting ${subject} `), type).toBe(true); + } + expect(isSecurityCatalogType('positions')).toBe(true); + expect(isSecurityCatalogType('views')).toBe(false); + }); + + it('CONTROL: admits the platform operator rung and a system write, for every type', () => { + for (const type of TYPES) { + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'isolated', callerPosture: PLATFORM_OPERATOR_POSTURE }), type) + .toEqual({ allowed: true }); + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'group', isSystem: true }), type) + .toEqual({ allowed: true }); + } }); it('a caller with no rung at all is not the operator', () => { - expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated' }).allowed).toBe(false); + expect(walledCatalogWriteVerdict({ type: 'view', tenancyPosture: 'isolated' }).allowed).toBe(false); }); - it('is inert under `single` and with no posture (no `tenancy` service): the capability gate decides alone', () => { + it('CONTROL: 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 }); + for (const type of TYPES) { + expect(walledCatalogWriteVerdict({ type, tenancyPosture, ...TENANT }), `${type} under ${tenancyPosture}`) + .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/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index 0a07e04efb5..c79fc9c86d9 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -35,12 +35,18 @@ * ── What deliberately does NOT consult this predicate ───────────────────── * * - `POST /meta/_migrate-stored` (both transports): an install-wide stored- - * metadata rewrite, gated on `manage_metadata` by its own door. + * metadata rewrite, gated on `manage_metadata` by its own door (and, under + * a wall, by the walled predicate below). * - Every non-`/meta` `manage_metadata` gate (automation flow authoring, * package management, activation toggles, datasource admin). * - The read path, which carries no capability gate (ADR-0106 masking is * the read-side posture). * + * Under a wall, the doors that write the environment's metadata ask a second + * predicate after their capability gate, {@link walledCatalogWriteVerdict}: + * the `/meta` item write doors, `POST /meta/_migrate-stored`, the `/packages` + * write doors, the `/automation` definition writes and `install-local`. + * * ── Refusal messages (#7450) ────────────────────────────────────────────── * * A refusal names the capability that would admit ANY caller and says nothing @@ -96,7 +102,7 @@ export function metaWriteCapabilityVerdict(input: { } // ───────────────────────────────────────────────────────────────────────────── -// [ADR-0131 D3] The walled catalog refusal +// [ADR-0131 D3 · D12 items 7–8 · D11] The walled environment-metadata refusal // ───────────────────────────────────────────────────────────────────────────── /** @@ -104,24 +110,37 @@ export function metaWriteCapabilityVerdict(input: { * 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. + * + * [#23231] The walled refusal no longer narrows to these three: under a wall + * every environment-metadata write is the platform operator's + * ({@link walledCatalogWriteVerdict}). The list still names the refusal's + * subject for a catalog type. */ 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. + * type? It decides no refusal: {@link walledCatalogWriteVerdict} judges every + * type alike, and a door asks that verdict for every write rather than + * pre-filtering on this predicate. */ 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> = { +/** + * The subject the refusal names, per written type: the catalog's own nouns, a + * package for the package doors (which act on a package's items, whatever + * their types), the whole stored ledger for the install-wide rewrite + * (`POST /meta/_migrate-stored`, which rewrites every `sys_metadata` row), and + * any other metadata type by its name. + */ +const WRITTEN_SUBJECT: Readonly> = { position: 'a position', permission: 'a permission set', capability: 'a capability', + package: 'a package', + sys_metadata: 'the environment\'s stored metadata', }; /** @@ -135,26 +154,37 @@ const CATALOG_NOUN: Readonly> = { 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). + * [ADR-0131 D3 · D12 items 7–8 · D11] May this caller write the environment's + * metadata, on a deployment running this tenancy posture? + * + * ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one + * catalog … The metadata-authoring capability (`manage_metadata` / + * `studio.access`) is what headquarters IT holds and plant admins do not." + * Item 8: a plant "cannot create a position, a permission set, a capability, a + * template or any metadata … A plant that needs its own catalog is given its + * own environment." D11: the rules are identical in every posture, and only + * whether the wall is enforced differs. The environment definition ledger + * carries no organization column (D6/D7), so under a wall a write by one + * organization's principal changes what every other organization resolves. + * That was measured, not argued, for every type and every door this predicate + * guards: an organization-scoped `manage_metadata` holder created an object, a + * view and a flow, edited, published, rolled back and reset the operator's + * items, and installed, upgraded and removed packages, and another + * organization's member read each result + * (`wall-environment-metadata-refusal.dogfood.test.ts`). The security catalog + * (D3) was the first case refused (`wall-catalog-refusal.dogfood.test.ts`); + * ruling A on #23231 (record 6145019463) stops special-casing it. + * + * This is the ONE predicate for that refusal, and it judges every type alike: + * no door pre-filters on the written type before asking it. Every door that + * writes the environment's metadata asks it after its own capability gate: the + * `/meta` item write doors on both transports (save, reset, publish, + * rollback), `POST /meta/_migrate-stored` on both transports, the `/packages` + * write doors, the `/automation` definition writes, + * `install-local` (install, upgrade, uninstall), the permission-set clone + * door 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. * * Admitted under a wall: `isSystem` (the engine's own writes: seeders, * installers, migrations) and the platform operator rung. Refused: everyone @@ -162,12 +192,19 @@ export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN'; * 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: + * Assignments are not metadata 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"). + * + * The name keeps "catalog": the security catalog was the refusal's first + * scope, and the export is read under this name. */ export function walledCatalogWriteVerdict(input: { - /** The metadata type written. A type outside {@link SECURITY_CATALOG_TYPES} is always admitted. */ + /** + * The metadata type written (`package` at the package doors, + * `sys_metadata` at the stored-metadata rewrite). It names the refusal's + * subject and never decides the verdict. + */ type: string; /** The deployment's EFFECTIVE tenancy posture; `undefined` when no `tenancy` service answers. */ tenancyPosture: TenancyPosture | undefined; @@ -175,20 +212,21 @@ export function walledCatalogWriteVerdict(input: { /** 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 }; + // The fold `saveMetaItem` applies at its own request boundary, so a URL + // spelling the protocol accepts (a plural) is named as the type it writes. + const type = canonicalMetaUrlType(input.type); + const subject = WRITTEN_SUBJECT[type] ?? `a '${type}' metadata item`; 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.`, + `Creating, changing or deleting ${subject} requires the platform operator: this deployment runs the ` + + `'${input.tenancyPosture}' tenancy posture, where every organization shares the environment's metadata, ` + + `the security catalog included (ADR-0131 D3, D12 items 7–8, D11). 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; an organization that needs metadata of ` + + `its own is given an environment of its own.`, }; } diff --git a/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts new file mode 100644 index 00000000000..14017868c4b --- /dev/null +++ b/packages/qa/dogfood/test/wall-environment-metadata-refusal.dogfood.test.ts @@ -0,0 +1,347 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D12 items 7–8 · D11] Under a wall every environment-metadata write +// is the platform operator's, on a REAL booted stack. +// +// ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one +// catalog … The metadata-authoring capability (`manage_metadata` / +// `studio.access`) is what headquarters IT holds and plant admins do not." +// Item 8: a plant "cannot create a position, a permission set, a capability, a +// template or any metadata … A plant that needs its own catalog is given its +// own environment." D11: the rules are identical in every posture; only whether +// the wall is enforced differs. Ruling A on #23231 (record 6145019463) stops +// special-casing the security catalog, which W, W2 and W3 refused first +// (`wall-catalog-refusal`, `wall-package-refusal`, +// `wall-package-doors-refusal`). +// +// What this harness measured before the refusal, under both walled postures: +// organization A's owner, granted `manage_metadata` inside organization A only +// (rung `TENANT_ADMIN`, never the platform operator), answered 2xx at every +// door below on metadata carrying no catalog item — `/meta` save (an object, a +// view and a flow created; the operator's view edited), publish, rollback and +// reset of the operator's items; `POST /meta/_migrate-stored`, the install-wide +// rewrite of the stored metadata; all thirteen `/packages` write doors; +// `install-local` install, upgrade and uninstall; and the `/automation` +// definition create — and organization B's owner read every result through +// `GET /meta`. One organization's write changed what another organization +// resolves. +// +// Pinned here, under each walled posture: every one of those doors refuses A +// (`403 FORBIDDEN` on the REST `/meta` doors and `install-local`, +// `403 PERMISSION_DENIED` on the `/packages` and `/automation` doors), the +// message names the posture and ADR-0131 D12 items 7–8, and B reads what the +// operator wrote, unchanged. CONTROLS: the platform operator runs every door; +// and under `single` the same `manage_metadata` holder, who is not the +// platform operator, runs them as before. +// +// 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. 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'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; +const INSTALL_LOCAL = '/marketplace/install-local'; + +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 view = (name: string, label: string) => ({ + name, object: 'crm_lead', viewKind: 'list', label, config: { type: 'grid', columns: [{ field: 'name' }] }, +}); +const object = (name: string, label: string) => ({ + name, label, sharingModel: 'public_read_write', fields: { title: { type: 'text', label: 'Title' } }, +}); +const flow = (name: string, label: string) => ({ + name, label, type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start', config: {} }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); +/** An install-local body for a tiny app stack carrying the objects given. */ +function packageBody(id: string, version: string, objects: unknown[] = []) { + const stackDef = { manifest: { id, name: id, version, type: 'app' }, objects }; + const { artifact } = buildShapedArtifact(stackDef as unknown as Record); + return { manifest: { ...artifact, id, version } }; +} + +const META_AUTHOR = 'em_meta_author'; +const CATALOG_PKG = 'com.em.catalog'; + +interface Harness { + stack: VerifyStack; + ql: Engine; + operator: string; + tokA: string; + tokB: string; + userA: string; + orgA?: string; +} + +/** + * Boot, sign in the operator, A and B; under a wall each of A and B creates an + * organization. The operator saves `META_AUTHOR` (carrying `manage_metadata`) + * into a package of its own, and A holds it (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 storageDir = mkdtempSync(join(tmpdir(), `em-wall-${posture}-`)); + const stack = await bootStack(crmStack as never, { + ...(walled ? { multiTenant: 'posture-only' as const } : {}), + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + } as never); + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const operator = await stack.signIn(); + const tokA = await stack.signUp(`em-a-${posture}@wall.test`); + const tokB = await stack.signUp(`em-b-${posture}@wall.test`); + const h: Harness = { stack, ql, operator, tokA, tokB, userA: '' }; + if (walled) { + for (const [tok, slug] of [[tokA, `em-a-${posture}`], [tokB, `em-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); + if (tok === tokA) h.orgA = ((await created.json()) as { id: string }).id; + const active = await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + } + h.userA = String((await ql.find('sys_user', { where: { email: `em-a-${posture}@wall.test` }, limit: 1, context: SYS }))[0]?.id); + const created = await stack.apiAs(operator, 'POST', '/packages', { id: CATALOG_PKG, name: 'catalog', version: '1.0.0', type: 'app' }); + expect(created.status, await created.clone().text()).toBe(201); + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${META_AUTHOR}?package=${CATALOG_PKG}`, set(META_AUTHOR, ['manage_metadata'])); + expect(saved.status, await saved.clone().text()).toBe(200); + await ql.insert('sys_user_permission_set', { user_id: h.userA, permission_set: META_AUTHOR, ...(h.orgA ? { organization_id: h.orgA } : {}) }, + { context: { ...SYS, ...(h.orgA ? { tenantId: h.orgA } : {}) } }); + return h; +} + +/** What organization B's owner reads at `GET /meta/:type/:name`: the status and the label. */ +async function readAsB(h: Harness, type: string, name: string): Promise<{ status: number; label: unknown }> { + const res = await h.stack.apiAs(h.tokB, 'GET', `/meta/${type}/${name}`); + const body = res.status === 200 ? ((await res.json()) as { item?: { label?: unknown } }) : undefined; + return { status: res.status, label: body?.item?.label }; +} + +async function ok(res: Response, status = 200): Promise { + expect(res.status, await res.clone().text()).toBe(status); +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D12 items 7–8] ${posture}: every environment-metadata write is the platform operator's`, () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + const p = posture.slice(0, 3); + const refused = async (res: Response, code: 'FORBIDDEN' | 'PERMISSION_DENIED') => { + const env = await envelopeOf(res); + expect({ status: env.status, code: env.code }, env.message).toEqual({ status: 403, code }); + expect(env.message).toContain(`'${posture}' tenancy posture`); + expect(env.message).toContain('ADR-0131 D3, D12 items 7–8, D11'); + }; + + 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 and is not the platform operator', 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 resolveUserAuthzGrants(h.ql as never, h.userA, { tenantId: h.orgA }); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const session = (await (await h.stack.apiAs(h.tokA, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).toBe(false); + expect(session.session?.activeOrganizationId).toBe(h.orgA); + }); + + it('/meta save: A creating an object, a view and a flow is refused 403 FORBIDDEN, and B reads none of them', async () => { + for (const [type, name, body] of [ + ['object', `em_${p}_obj`, object(`em_${p}_obj`, 'Object by A')], + ['view', `crm_lead.em_${p}_view`, view(`crm_lead.em_${p}_view`, 'View by A')], + ['flow', `em_${p}_flow`, flow(`em_${p}_flow`, 'Flow by A')], + ] as const) { + await refused(await h.stack.apiAs(h.tokA, 'PUT', `/meta/${type}/${name}`, body), 'FORBIDDEN'); + expect((await readAsB(h, type, name)).status, `${type} ${name}`).toBe(404); + } + }); + + it("/meta save: A editing the operator's view is refused, and B reads the operator's label", async () => { + const name = `crm_lead.em_${p}_opview`; + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'Operator'))); + await refused(await h.stack.apiAs(h.tokA, 'PUT', `/meta/view/${name}`, view(name, 'Edited by A')), 'FORBIDDEN'); + expect(await readAsB(h, 'view', name)).toEqual({ status: 200, label: 'Operator' }); + }); + + it("/meta publish: A publishing the operator's pending draft is refused, and B reads the published version", async () => { + const name = `crm_lead.em_${p}_pub`; + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'Published v1'))); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}?mode=draft`, view(name, 'Operator draft'))); + await refused(await h.stack.apiAs(h.tokA, 'POST', `/meta/view/${name}/publish`, {}), 'FORBIDDEN'); + expect(await readAsB(h, 'view', name)).toEqual({ status: 200, label: 'Published v1' }); + }); + + it("/meta rollback: A rolling the operator's view back is refused, and B reads the current version", async () => { + const name = `crm_lead.em_${p}_rb`; + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'Operator v1'))); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'Operator v2'))); + await refused(await h.stack.apiAs(h.tokA, 'POST', `/meta/view/${name}/rollback`, { toVersion: 1 }), 'FORBIDDEN'); + expect(await readAsB(h, 'view', name)).toEqual({ status: 200, label: 'Operator v2' }); + }); + + it("/meta reset: A resetting the operator's view is refused, and B still reads it", async () => { + const name = `crm_lead.em_${p}_rs`; + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'Operator reset target'))); + await refused(await h.stack.apiAs(h.tokA, 'DELETE', `/meta/view/${name}`), 'FORBIDDEN'); + expect(await readAsB(h, 'view', name)).toEqual({ status: 200, label: 'Operator reset target' }); + }); + + it("/meta/_migrate-stored: A's install-wide rewrite of the stored metadata is refused 403 FORBIDDEN, preview and apply alike", async () => { + for (const body of [{}, { apply: true }]) { + await refused(await h.stack.apiAs(h.tokA, 'POST', '/meta/_migrate-stored', body), 'FORBIDDEN'); + } + }); + + it("/packages: each of the thirteen write doors refuses A 403 PERMISSION_DENIED, and B reads the operator's package unchanged", async () => { + const pkg = `com.em.${p}.op`; + const pv = `crm_lead.em_${p}_pv`; + await ok(await h.stack.apiAs(h.operator, 'POST', '/packages', { id: pkg, name: 'op', version: '1.0.0', type: 'app' }), 201); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${pv}?package=${pkg}&mode=draft`, view(pv, 'c1'))); + await ok(await h.stack.apiAs(h.operator, 'POST', `/packages/${pkg}/publish-drafts`, {})); + const commits = (((await (await h.stack.apiAs(h.operator, 'GET', `/packages/${pkg}/commits`)).json()) as any).data?.commits ?? []) + .map((c: { id: string }) => c.id); + expect(commits.length).toBeGreaterThan(0); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${pv}?package=${pkg}&mode=draft`, view(pv, 'pending'))); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/crm_lead.em_${p}_orphan`, view(`crm_lead.em_${p}_orphan`, 'Orphan'))); + + for (const [method, path, body] of [ + ['POST', '/packages', { id: `com.em.${p}.a`, name: 'a', version: '1.0.0', type: 'app' }], + ['PATCH', `/packages/${pkg}`, { description: 'by A' }], + ['PATCH', `/packages/${pkg}/disable`, {}], + ['PATCH', `/packages/${pkg}/enable`, {}], + ['POST', `/packages/${pkg}/publish`, {}], + ['POST', `/packages/${pkg}/publish-drafts`, {}], + ['POST', `/packages/${pkg}/revert`, {}], + ['POST', `/packages/${pkg}/discard-drafts`, {}], + ['POST', `/packages/${pkg}/rollback`, { commitId: commits[0] }], + ['POST', `/packages/${pkg}/commits/${commits[0]}/revert`, {}], + ['POST', `/packages/${pkg}/duplicate`, { targetPackageId: `com.em.${p}.dup` }], + ['POST', `/packages/${pkg}/adopt-orphans`, {}], + ['DELETE', `/packages/${pkg}`, undefined], + ] as const) { + await refused(await h.stack.apiAs(h.tokA, method, path, body), 'PERMISSION_DENIED'); + } + expect(await readAsB(h, 'view', pv)).toEqual({ status: 200, label: 'c1' }); + const pending = await h.ql.find('sys_metadata', { where: { package_id: pkg, name: pv, state: 'draft' }, context: SYS }); + expect(pending).toHaveLength(1); + expect((await h.stack.apiAs(h.operator, 'GET', `/packages/com.em.${p}.a`)).status).toBe(404); + expect(await h.ql.find('sys_metadata', { where: { package_id: `com.em.${p}.dup` }, context: SYS })).toHaveLength(0); + expect((await h.stack.apiAs(h.operator, 'GET', `/packages/${pkg}`)).status).toBe(200); + }, 60_000); + + it("install-local: A's install, upgrade and uninstall are refused 403 FORBIDDEN, and B reads the operator's object unchanged", async () => { + const op = `com.em.${p}.il`; + await ok(await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, packageBody(op, '1.0.0', [object(`em_${p}_ilobj`, 'IL v1')]))); + await refused(await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody(`com.em.${p}.ila`, '1.0.0', [object(`em_${p}_ilaobj`, 'A install')])), 'FORBIDDEN'); + expect((await readAsB(h, 'object', `em_${p}_ilaobj`)).status).toBe(404); + await refused(await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody(op, '1.1.0', [object(`em_${p}_ilobj`, 'IL v2 by A')])), 'FORBIDDEN'); + await refused(await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/${op}`), 'FORBIDDEN'); + expect(await readAsB(h, 'object', `em_${p}_ilobj`)).toEqual({ status: 200, label: 'IL v1' }); + }, 60_000); + + it("/automation: A's create, update, deregister and clone of a flow definition are refused 403 PERMISSION_DENIED", async () => { + const name = `em_${p}_opflow`; + await ok(await h.stack.apiAs(h.operator, 'POST', '/automation', flow(name, 'Operator flow'))); + await refused(await h.stack.apiAs(h.tokA, 'POST', '/automation', flow(`em_${p}_aflow`, 'Flow by A')), 'PERMISSION_DENIED'); + await refused(await h.stack.apiAs(h.tokA, 'PUT', `/automation/${name}`, flow(name, 'Edited by A')), 'PERMISSION_DENIED'); + await refused(await h.stack.apiAs(h.tokA, 'POST', `/automation/${name}/clone`, { name: `em_${p}_aclone`, label: 'Clone by A' }), 'PERMISSION_DENIED'); + await refused(await h.stack.apiAs(h.tokA, 'DELETE', `/automation/${name}`), 'PERMISSION_DENIED'); + expect((await readAsB(h, 'flow', `em_${p}_aflow`)).status).toBe(404); + expect((await readAsB(h, 'flow', `em_${p}_aclone`)).status).toBe(404); + expect((await h.stack.apiAs(h.operator, 'GET', `/automation/${name}`)).status).toBe(200); + }, 60_000); + + it('CONTROL: the platform operator writes at every one of these doors', async () => { + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/object/em_${p}_opobj`, object(`em_${p}_opobj`, 'Operator object'))); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/flow/em_${p}_opmetaflow`, flow(`em_${p}_opmetaflow`, 'Operator flow'))); + const name = `crm_lead.em_${p}_opctl`; + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'v1'))); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}`, view(name, 'v2'))); + await ok(await h.stack.apiAs(h.operator, 'POST', `/meta/view/${name}/rollback`, { toVersion: 1 })); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/${name}?mode=draft`, view(name, 'draft'))); + await ok(await h.stack.apiAs(h.operator, 'POST', `/meta/view/${name}/publish`, {})); + expect(await readAsB(h, 'view', name)).toEqual({ status: 200, label: 'draft' }); + await ok(await h.stack.apiAs(h.operator, 'DELETE', `/meta/view/${name}`)); + + const pkg = `com.em.${p}.opctl`; + await ok(await h.stack.apiAs(h.operator, 'POST', '/packages', { id: pkg, name: 'opctl', version: '1.0.0', type: 'app' }), 201); + await ok(await h.stack.apiAs(h.operator, 'PATCH', `/packages/${pkg}`, { description: 'by the operator' })); + await ok(await h.stack.apiAs(h.operator, 'PUT', `/meta/view/crm_lead.em_${p}_pkgv?package=${pkg}&mode=draft`, view(`crm_lead.em_${p}_pkgv`, 'v1'))); + await ok(await h.stack.apiAs(h.operator, 'POST', `/packages/${pkg}/publish-drafts`, {})); + await ok(await h.stack.apiAs(h.operator, 'POST', `/packages/${pkg}/duplicate`, { targetPackageId: `com.em.${p}.opctl.copy` })); + await ok(await h.stack.apiAs(h.operator, 'DELETE', `/packages/${pkg}`)); + + const il = `com.em.${p}.opil`; + await ok(await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, packageBody(il, '1.0.0', [object(`em_${p}_opilobj`, 'v1')]))); + await ok(await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, packageBody(il, '1.1.0', [object(`em_${p}_opilobj`, 'v2')]))); + expect(await readAsB(h, 'object', `em_${p}_opilobj`)).toEqual({ status: 200, label: 'v2' }); + await ok(await h.stack.apiAs(h.operator, 'DELETE', `${INSTALL_LOCAL}/${il}`)); + + await ok(await h.stack.apiAs(h.operator, 'POST', '/automation', flow(`em_${p}_opautoflow`, 'Operator automation'))); + await ok(await h.stack.apiAs(h.operator, 'DELETE', `/automation/em_${p}_opautoflow`)); + await ok(await h.stack.apiAs(h.operator, 'POST', '/meta/_migrate-stored', {})); + }, 60_000); + }); +} + +describe('[ADR-0131 D12 items 7–8 · D11] 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 writes at the /meta (the stored rewrite included), /packages, install-local and /automation doors as before', async () => { + const a = await resolveUserAuthzGrants(h.ql as never, h.userA, {}); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + await ok(await h.stack.apiAs(h.tokA, 'PUT', '/meta/object/em_sin_obj', object('em_sin_obj', 'Object by A'))); + await ok(await h.stack.apiAs(h.tokA, 'PUT', '/meta/view/crm_lead.em_sin_view', view('crm_lead.em_sin_view', 'View by A'))); + await ok(await h.stack.apiAs(h.tokA, 'PUT', '/meta/flow/em_sin_flow', flow('em_sin_flow', 'Flow by A'))); + expect(await readAsB(h, 'view', 'crm_lead.em_sin_view')).toEqual({ status: 200, label: 'View by A' }); + await ok(await h.stack.apiAs(h.tokA, 'POST', '/packages', { id: 'com.em.sin.a', name: 'a', version: '1.0.0', type: 'app' }), 201); + await ok(await h.stack.apiAs(h.tokA, 'PUT', '/meta/view/crm_lead.em_sin_pv?package=com.em.sin.a&mode=draft', view('crm_lead.em_sin_pv', 'v1'))); + await ok(await h.stack.apiAs(h.tokA, 'POST', '/packages/com.em.sin.a/publish-drafts', {})); + await ok(await h.stack.apiAs(h.tokA, 'DELETE', '/packages/com.em.sin.a')); + await ok(await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody('com.em.sin.il', '1.0.0', [object('em_sin_ilobj', 'v1')]))); + await ok(await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/com.em.sin.il`)); + await ok(await h.stack.apiAs(h.tokA, 'POST', '/automation', flow('em_sin_autoflow', 'Automation by A'))); + await ok(await h.stack.apiAs(h.tokA, 'POST', '/meta/_migrate-stored', {})); + }, 60_000); +}); 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 index 301f8fb2748..23c6bf10f93 100644 --- a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts @@ -22,10 +22,8 @@ // 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 +// [ADR-0131 D12 items 7–8] A catalog-free package is refused to A the same way +// (ruling A on #23231 stops special-casing the catalog). CONTROLS: 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. @@ -223,8 +221,10 @@ for (const posture of ['isolated', 'group'] as const) { 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' }); + it('commits/:commitId/revert: the commit is refused whatever package the URL names', async () => { + // [ADR-0131 D12 items 7–8] A can no longer create a package under a wall, + // so the other package the URL names is the operator's. + const own = await call(h.stack, h.operator, '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`, {})); @@ -236,33 +236,40 @@ for (const posture of ['isolated', 'group'] as const) { 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' }); + it('adopt-orphans: A adopting the environment\'s catalog orphans into another package is refused, and the orphan stays unbound', async () => { + // [ADR-0131 D12 items 7–8] A can no longer create a package under a wall, + // so the adopting package is the operator's. + const own = await call(h.stack, h.operator, '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'; + it('[ADR-0131 D12 items 7–8] a catalog-free package is the platform operator\'s too: A is refused every door on it, and nothing moves', async () => { + // Ruling A on #23231 stops special-casing the catalog: what was this + // file's catalog-free CONTROL (A runs every door on its own package as + // before) is refused now. A cannot create a package under a wall, so the + // catalog-free package and its commits are the operator's. + const pkg = 'com.w3.op.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' })); + const created = await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.free', name: 'free', version: '1.0.0', type: 'app' }); + await refused(created); + expect((await call(h.stack, h.operator, 'POST', '/packages', { id: pkg, name: 'free', version: '1.0.0', type: 'app' })).status).toBe(201); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v1'))); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${pkg}/publish-drafts`, {})); + const first = (await commitIds(h.stack, h.operator, pkg))[0]!; + await ok(await call(h.stack, h.operator, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v2'))); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/publish-drafts`, {})); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/revert`, {})); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: pkg, state: 'draft' })).toHaveLength(1); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/rollback`, { commitId: first })); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/commits/${first}/revert`, {})); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/duplicate`, { targetPackageId: 'com.w3.a.free.copy' })); + expect(await rowsOf(h, { package_id: 'com.w3.a.free.copy' })).toHaveLength(0); + expect(await commitIds(h.stack, h.operator, pkg)).toHaveLength(1); }); it('CONTROL: the platform operator runs every one of these doors on its catalog-carrying package', async () => { diff --git a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts index dabfca26122..4cf22417626 100644 --- a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts @@ -30,9 +30,10 @@ // resolution does not move; // - the `/packages` delete of a package holding stored catalog items answers // `403 PERMISSION_DENIED`, and B's resolution does not move; -// - CONTROLS: A installs, upgrades and uninstalls a catalog-free package and -// deletes a catalog-free runtime package as before; the platform operator -// upgrades, uninstalls and deletes the catalog-carrying ones. +// - [ADR-0131 D12 items 7–8] A catalog-free package is refused to A the same +// way (ruling A on #23231 stops special-casing the catalog); +// - CONTROLS: the platform operator upgrades, uninstalls and deletes the +// catalog-carrying ones. // And under `single` (CONTROL): the same `manage_metadata` holder, who is not // the platform operator, does all of it as before. // @@ -224,17 +225,26 @@ for (const posture of ['isolated', 'group'] as const) { expect(still.status).toBe(200); }); - it('CONTROL: A installs, upgrades and uninstalls a catalog-free package, and deletes a catalog-free runtime package, as before', async () => { - for (const version of ['1.0.0', '1.1.0']) { - const res = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody('com.w2.free', version)); - expect(res.status, await res.clone().text()).toBe(200); - } - const removed = await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/com.w2.free`); - expect(removed.status, await removed.clone().text()).toBe(200); + it('[ADR-0131 D12 items 7–8] a catalog-free package is the platform operator\'s too: A\'s install, upgrade and uninstall answer 403 FORBIDDEN, and A\'s /packages delete 403 PERMISSION_DENIED; nothing moves', async () => { + // Ruling A on #23231 stops special-casing the catalog: what was this + // file's catalog-free CONTROL (A installs, upgrades, uninstalls and + // deletes as before) is refused now, whatever the package carries. + const installed = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody('com.w2.free', '1.0.0')); + expect(await envelopeOf(installed)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + const listedFree = await h.stack.apiAs(h.operator, 'GET', '/packages/com.w2.free'); + expect(listedFree.status).toBe(404); + const opInstalled = await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, packageBody('com.w2.free2', '1.0.0')); + expect(opInstalled.status, await opInstalled.clone().text()).toBe(200); + const upgraded = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody('com.w2.free2', '1.1.0')); + expect(await envelopeOf(upgraded)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + const removed = await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/com.w2.free2`); + expect(await envelopeOf(removed)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + expect((await h.stack.apiAs(h.operator, 'GET', '/packages/com.w2.free2')).status).toBe(200); const created = await h.stack.apiAs(h.operator, 'POST', '/packages', { id: 'com.w2.freert', name: 'free', version: '1.0.0', type: 'app' }); expect(created.status, await created.clone().text()).toBe(201); const deleted = await h.stack.apiAs(h.tokA, 'DELETE', '/packages/com.w2.freert'); - expect(deleted.status, await deleted.clone().text()).toBe(200); + expect(await envelopeOf(deleted)).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + expect((await h.stack.apiAs(h.operator, 'GET', '/packages/com.w2.freert')).status).toBe(200); }); it('CONTROL: the platform operator installs, upgrades and uninstalls a catalog-carrying package, and deletes one at /packages', async () => { diff --git a/packages/rest/src/meta-migrate-stored-walled-refusal.test.ts b/packages/rest/src/meta-migrate-stored-walled-refusal.test.ts new file mode 100644 index 00000000000..cdca39ef206 --- /dev/null +++ b/packages/rest/src/meta-migrate-stored-walled-refusal.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D12 items 7–8 · D11] `POST /meta/_migrate-stored` on `RestServer` + * asks the walled refusal (`walledCatalogWriteVerdict`, + * `@objectstack/metadata-core`) right after its own `manage_metadata` gate: the + * route rewrites every stored row of the environment's metadata, so under + * `isolated` / `group` it is the platform operator's (ruling A on #23231). A + * preview is refused too: it is the same install-wide pass, without the write. + * + * Pinned here, on this transport: the refusal's envelope (`403 FORBIDDEN`, the + * door's own spelling, as its sibling capability refusal), that nothing reaches + * `migrateStoredMetadata`, and the controls — the platform operator rung, + * `isSystem`, `single` and no `tenancy` service — which run it as before. The + * dispatcher's twin is pinned in `@objectstack/runtime` + * (`meta-walled-catalog-refusal.test.ts`), and the booted door in + * `wall-environment-metadata-refusal.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; + +const PATH = '/api/v1/meta/_migrate-stored'; + +/** 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 SYSTEM = { userId: 'u_system', isSystem: true }; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(), + }; + return res; +} + +/** + * Boot the route with the caller `context`, on a deployment whose `tenancy` + * service reports `posture` (`null`: no `tenancy` service at all). + */ +function boot(posture: 'single' | 'isolated' | 'group' | null, context: Record) { + const migrateStoredMetadata = vi.fn(async () => ({ + apply: false, scanned: 0, canonical: 0, pending: 0, rewritten: 0, skipped: 0, failed: 0, rows: [], + })); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue(null), + migrateStoredMetadata, + }; + const rest: any = new RestServer( + mockServer() as any, + protocol, + {} as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, + posture ? (async () => ({ posture })) : undefined, + ); + rest.resolveExecCtx = async () => context; + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === PATH); + if (!route) throw new Error(`POST ${PATH} is not registered`); + return { + migrateStoredMetadata, + call: async (body: unknown) => { + const res = mockRes(); + await route.handler({ params: {}, query: {}, headers: {}, body }, res); + const answer = res.json.mock.calls.at(-1)?.[0]; + return { status: res.statusCode as number, code: answer?.error?.code, message: String(answer?.error?.message ?? '') }; + }, + }; +} + +describe('[ADR-0131 D12 items 7–8] REST POST /meta/_migrate-stored — the walled refusal', () => { + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: a tenant's install-wide rewrite is refused 403 FORBIDDEN naming the posture, preview and apply alike, and nothing is read`, async () => { + for (const body of [{}, { apply: true }]) { + const stack = boot(posture, TENANT_AUTHOR); + const answer = await stack.call(body); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(answer.message).toContain(`'${posture}' tenancy posture`); + expect(answer.message).toContain("the environment's stored metadata"); + expect(stack.migrateStoredMetadata).not.toHaveBeenCalled(); + } + }); + + it(`${posture}: CONTROL — the platform operator rung and the engine itself run it`, async () => { + for (const caller of [OPERATOR, SYSTEM]) { + const stack = boot(posture, caller); + const answer = await stack.call({ apply: true }); + expect(answer.status, caller.userId).toBe(200); + expect(stack.migrateStoredMetadata, caller.userId).toHaveBeenCalledTimes(1); + } + }); + } + + it('CONTROL — under `single` and with no `tenancy` service the same tenant holder runs it, unchanged', async () => { + for (const posture of ['single', null] as const) { + const stack = boot(posture, TENANT_AUTHOR); + const answer = await stack.call({}); + expect(answer.status, String(posture)).toBe(200); + expect(stack.migrateStoredMetadata, String(posture)).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', { userId: 'u_x', systemPermissions: [] }); + const answer = await stack.call({}); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(answer.message).toContain('requires the `manage_metadata` capability'); + expect(answer.message).not.toContain('tenancy posture'); + expect(stack.migrateStoredMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9d7d00f193c..d9e09313499 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -73,8 +73,8 @@ 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, + // [ADR-0131 D3 · D12 items 7–8] The walled refusal the same four doors ask + // next, for every type they write. walledCatalogWriteVerdict, // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. @@ -3657,12 +3657,14 @@ 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 + * [ADR-0131 D3 · D12 items 7–8] The walled refusal for one `/meta` write: + * the shared `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), + * asked by this transport's four item write doors (save, reset, publish, + * rollback) after {@link metaSaveVerdict}'s capability gate, and by + * `POST /meta/_migrate-stored` after its own, for every type + * (ruling A on #23231: the catalog is no longer a special case, so this + * method no longer pre-filters on it). 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), @@ -3674,7 +3676,6 @@ export class RestServer { environmentId: string | undefined, req: any, ): Promise { - if (!isSecurityCatalogType(type)) return { allowed: true }; return walledCatalogWriteVerdict({ type, tenancyPosture: effectiveTenancyPosture(await this.resolveAnonymousFormTenancy(environmentId, req)), @@ -5941,6 +5942,26 @@ export class RestServer { }); return; } + // [ADR-0131 D12 items 7–8] The walled refusal, after the + // capability gate and before the protocol is resolved or + // a row is read: this rewrites every stored row of the + // environment's metadata, which under `isolated` / + // `group` is the platform operator's (ruling A on + // #23231). A preview is refused too: it is the same + // install-wide pass, without the write. Inert under + // `single`. See {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal('sys_metadata', 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 (typeof (p as any).migrateStoredMetadata !== 'function') { res.status(501).json({ @@ -7049,13 +7070,12 @@ export class RestServer { return; } } - // [ADR-0131 D3] The walled catalog refusal, after the + // [ADR-0131 D3 · D12 items 7–8] The walled 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}. + // `isolated` / `group` every organization shares the + // environment's metadata, so only the platform operator rung + // (or the engine itself) writes it, whatever the type. Inert + // under `single`. See {@link walledCatalogRefusal}. { const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); if (!walled.allowed) { @@ -7275,13 +7295,12 @@ export class RestServer { return; } } - // [ADR-0131 D3] The walled catalog refusal, after the + // [ADR-0131 D3 · D12 items 7–8] The walled 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}. + // `isolated` / `group` every organization shares the + // environment's metadata, so only the platform operator rung + // (or the engine itself) writes it, whatever the type. Inert + // under `single`. See {@link walledCatalogRefusal}. { const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); if (!walled.allowed) { @@ -7745,13 +7764,12 @@ export class RestServer { return; } } - // [ADR-0131 D3] The walled catalog refusal, after the + // [ADR-0131 D3 · D12 items 7–8] The walled 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}. + // `isolated` / `group` every organization shares the + // environment's metadata, so only the platform operator rung + // (or the engine itself) writes it, whatever the type. Inert + // under `single`. See {@link walledCatalogRefusal}. { const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); if (!walled.allowed) { @@ -7930,13 +7948,12 @@ export class RestServer { return; } } - // [ADR-0131 D3] The walled catalog refusal, after the + // [ADR-0131 D3 · D12 items 7–8] The walled 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}. + // `isolated` / `group` every organization shares the + // environment's metadata, so only the platform operator rung + // (or the engine itself) writes it, whatever the type. Inert + // under `single`. See {@link walledCatalogRefusal}. { const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); if (!walled.allowed) { diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 3724549bfa4..362734cb149 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -106,10 +106,12 @@ export const ACTIVATION_AUTHORING_CAPABILITY = 'manage_metadata'; * refused caller can do instead. * * `remedy` is a whole sentence rather than a noun because the sanctioned path - * genuinely differs by artifact and ADR-0126 §7 requires a refusal to name one: - * a flow can be cloned under a new name (§7.1), while **action-clone is not - * chartered** (§8 item 2) — recommending one for actions would advertise - * machinery that does not exist, which is the failure Prime Directive #10 names. + * genuinely differs by artifact and ADR-0126 §7 requires a refusal to name one. + * The refusal is answered only under a wall, where ADR-0131 D12 items 7–8 make + * every environment-metadata write the platform operator's: a clone (§7.1) or + * a sibling item is environment authoring there, so no remedy may recommend + * one — that would advertise a door that refuses the reader, which is the + * failure Prime Directive #10 names. */ export interface ActivationSubject { /** Noun phrase completing "Enabling or disabling …" — e.g. `a packaged flow`. */ @@ -118,10 +120,20 @@ export interface ActivationSubject { remedy: string; } -/** The flow door's wording (#12157) — byte-identical to what it shipped with. */ +/** + * The flow door's wording (#12157). + * + * [ADR-0131 D6 · D12 items 7–8] This refusal is answered only under a wall, + * where cloning is environment authoring and the platform operator's like + * every other environment-metadata write (the `/automation` clone door and the + * `/meta` save refuse the organization's caller there), so the remedy names + * the operator and the organization's own environment, never a clone. + */ export const FLOW_ACTIVATION_SUBJECT: ActivationSubject = { subject: 'a packaged flow', - remedy: 'To customize this flow for your organization, clone it under a new name instead.', + remedy: + 'To switch or customize this flow for the installation, ask your platform operator; an organization that needs ' + + 'flows of its own is given an environment of its own.', }; /** @@ -129,14 +141,13 @@ export const FLOW_ACTIVATION_SUBJECT: ActivationSubject = { * * ⛔ It does NOT recommend a clone. The action-clone half is deliberately * unchartered ("it stays on §3's pre-chart discipline until real pull - * appears"), so the honest alternatives are the operator, and authoring an - * ordinary sibling action — which Regime C leaves open exactly as today. + * appears"). [ADR-0131 D12 items 7–8] Nor does it recommend authoring a + * sibling action: this refusal is answered only under a wall, where authoring + * one is the platform operator's too. The operator is the honest alternative. */ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = { subject: 'a packaged action', - remedy: - 'To switch a packaged action off for this installation, ask your platform operator; authoring your own action ' + - 'alongside it stays open to you.', + remedy: 'To switch a packaged action off for this installation, ask your platform operator.', }; /** @@ -156,12 +167,16 @@ export const POSITION_ACTIVATION_SUBJECT: ActivationSubject = { /** * [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] The permission-set door's - * wording. Regime C charters the clone for permission sets (the "Clone" action - * the packaged-base refusal already names), so the remedy is the flow door's. + * wording. Regime C charters the clone for permission sets, but this refusal is + * answered only under a wall, where the clone door refuses the organization's + * caller (ADR-0131 D3), so the remedy names the operator, as the flow door's + * does. */ export const PERMISSION_SET_ACTIVATION_SUBJECT: ActivationSubject = { subject: 'a permission set', - remedy: 'To customize this permission set for your organization, clone it under a new name instead.', + remedy: + 'To switch or customize this permission set for the installation, ask your platform operator; an organization ' + + 'that needs a catalog of its own is given an environment of its own.', }; /** @@ -226,9 +241,10 @@ export function readActivationBody( * 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. + * [ADR-0131 D3 · D12 items 7–8] The walled refusal at the metadata, package + * and automation write doors reads it through this same function, so the + * posture-conditional gates of this transport cannot read two different + * postures. */ export async function readEffectiveTenancyPosture( deps: DomainHandlerDeps, diff --git a/packages/runtime/src/domains/automation-activation-posture-gate.test.ts b/packages/runtime/src/domains/automation-activation-posture-gate.test.ts index 5191a67e7a3..5c76cf8d09d 100644 --- a/packages/runtime/src/domains/automation-activation-posture-gate.test.ts +++ b/packages/runtime/src/domains/automation-activation-posture-gate.test.ts @@ -204,9 +204,13 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post expect(message).toContain(posture); expect(message).toContain('INSTALL-WIDE'); expect(message).toContain('ADR-0126 §5'); - // ADR-0126 §7: a refusal names what the caller CAN do. Here - // that is the clone path (§7.1), which needs no operator. - expect(message).toMatch(/clone/i); + // ADR-0126 §7: a refusal names what the caller CAN do. Under a + // wall that is the platform operator: cloning is environment + // authoring there, refused to the tenant like every other + // environment-metadata write (ADR-0131 D12 items 7–8), so the + // refusal must not recommend it. + expect(message).toContain('ask your platform operator'); + expect(message).not.toMatch(/clone/i); // #7450 — a denial says nothing about the caller's own // positions or permission-set names. expect(message).not.toContain('org_owner'); @@ -254,7 +258,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post expect(h.toggleFlow).not.toHaveBeenCalled(); }); - it('does NOT gate the clone door — cloning is the path the refusal recommends', async () => { + it('the clone door is not THIS gate, and under a wall it refuses the tenant too: a clone is environment authoring', async () => { const h = boot(posture); const { response } = await h.dispatcher.handleAutomation( @@ -262,10 +266,15 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post TENANT_ADMIN(), undefined, ); - // Whatever the clone route answers, it must not be THIS gate: - // a clone creates an ordinary new artifact and takes nothing - // away from any tenant (§7.1). - expect(codeOf(response)).not.toBe('PERMISSION_DENIED'); + // [ADR-0131 D12 items 7–8] A clone registers a new flow at + // environment scope, which under a wall is the platform + // operator's: the walled definition-write gate refuses it, + // naming the written type, not the activation switch. + expect(statusOf(response)).toBe(403); + expect(codeOf(response)).toBe('PERMISSION_DENIED'); + expect(messageOf(response)).toContain(`'${posture}' tenancy posture`); + expect(messageOf(response)).not.toContain('INSTALL-WIDE'); + expect(h.toggleFlow).not.toHaveBeenCalled(); }); it('does not over-block the legacy execution door for a flow named `toggle`', async () => { diff --git a/packages/runtime/src/domains/automation-walled-definition-write.test.ts b/packages/runtime/src/domains/automation-walled-definition-write.test.ts new file mode 100644 index 00000000000..e16aa57cb19 --- /dev/null +++ b/packages/runtime/src/domains/automation-walled-definition-write.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D12 items 7–8 · D11] The `/automation` definition writes — + * `POST /`, `PUT /:name`, `DELETE /:name` and `POST /:name/clone` — ask the + * walled refusal (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) + * right after the `manage_metadata` gate: a flow definition is registered at + * environment scope, so under `isolated` / `group` it is the platform + * operator's to create, change, deregister or clone (ruling A on #23231). + * + * Pinned here: a `manage_metadata` holder below the operator rung is refused + * `403 PERMISSION_DENIED` naming the posture before the automation service is + * touched; the controls — the operator rung, `isSystem`, `single` and no + * `tenancy` service — are not refused by it. The enablement door stays the + * ADR-0126 §5 activation gate's (`automation-activation-posture-gate.test.ts`). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const FLOW = 'vendor_lead_router'; +const DEFINITION = { + name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start', config: {} }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +function boot(posture: 'single' | 'isolated' | 'group' | null) { + const automation = { + handlerReady: true, + registerFlow: vi.fn(async () => undefined), + unregisterFlow: vi.fn(async () => undefined), + cloneFlow: vi.fn(async () => ({ name: 'my_router' })), + getFlow: vi.fn(async (name: string) => (name === FLOW ? DEFINITION : undefined)), + listFlows: vi.fn(async () => [FLOW]), + }; + const services: Record = { automation }; + if (posture) services.tenancy = { posture }; + const resolve = (name: string): unknown => services[name]; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel as never), automation }; +} + +const ctx = (executionContext: any): any => ({ request: {}, executionContext }); +/** A tenant org admin who holds `manage_metadata`, so the capability gate admits it and the walled refusal is what answers. */ +const TENANT_ADMIN = { userId: 'u_owner', systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN', tenantId: 'org_a' }; +const OPERATOR = { userId: 'u_operator', systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }; +const SYSTEM = { userId: 'usr_system', isSystem: true }; + +const WRITES: Array<{ path: string; method: string; body?: unknown }> = [ + { path: '/', method: 'POST', body: { ...DEFINITION, name: 'tenant_flow' } }, + { path: `/${FLOW}`, method: 'PUT', body: { ...DEFINITION, label: 'Edited' } }, + { path: `/${FLOW}`, method: 'DELETE' }, + { path: `/${FLOW}/clone`, method: 'POST', body: { name: 'my_router', label: 'My Router' } }, +]; + +const touched = (stack: ReturnType) => + (['registerFlow', 'unregisterFlow', 'cloneFlow'] as const).filter((fn) => stack.automation[fn].mock.calls.length > 0); + +describe('[ADR-0131 D12 items 7–8] /automation definition writes — the walled refusal', () => { + for (const posture of ['isolated', 'group'] as const) { + for (const write of WRITES) { + it(`${posture} · ${write.method} ${write.path}: a tenant holder is refused 403 PERMISSION_DENIED naming the posture, and the service is not touched`, async () => { + const stack = boot(posture); + const { response } = await stack.dispatcher.handleAutomation(write.path, write.method, write.body, ctx(TENANT_ADMIN), undefined); + expect((response as any)?.status).toBe(403); + expect((response as any)?.body?.error?.code).toBe('PERMISSION_DENIED'); + const message = String((response as any)?.body?.error?.message); + expect(message).toContain(`'${posture}' tenancy posture`); + expect(message).toContain("a 'flow' metadata item"); + expect(touched(stack)).toEqual([]); + }); + } + + it(`${posture}: CONTROL — the platform operator rung and the engine itself are not refused by it`, async () => { + for (const caller of [OPERATOR, SYSTEM]) { + for (const write of WRITES) { + const stack = boot(posture); + const { response } = await stack.dispatcher.handleAutomation(write.path, write.method, write.body, ctx(caller), undefined); + expect(String((response as any)?.body?.error?.message ?? ''), `${caller.userId} ${write.method} ${write.path}`) + .not.toContain('tenancy posture'); + expect((response as any)?.status, `${caller.userId} ${write.method} ${write.path}`).not.toBe(403); + } + } + }); + } + + it('CONTROL — under `single` and with no `tenancy` service the same tenant holder is not refused by it', async () => { + for (const posture of ['single', null] as const) { + for (const write of WRITES) { + const stack = boot(posture); + const { response } = await stack.dispatcher.handleAutomation(write.path, write.method, write.body, ctx(TENANT_ADMIN), undefined); + expect(String((response as any)?.body?.error?.message ?? ''), `${posture} ${write.method} ${write.path}`) + .not.toContain('tenancy posture'); + expect((response as any)?.status, `${posture} ${write.method} ${write.path}`).not.toBe(403); + } + } + }); + + 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 { response } = await stack.dispatcher.handleAutomation('/', 'POST', DEFINITION, ctx({ userId: 'u_x', systemPermissions: [] }), undefined); + expect((response as any)?.status).toBe(403); + expect(String((response as any)?.body?.error?.message)).toContain('requires the `manage_metadata` capability'); + expect(touched(stack)).toEqual([]); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index f5fee112bf9..c0c8512b768 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -19,7 +19,11 @@ import { handleInboundHookRequest } from './automation-hooks.js'; // [ADR-0126 §5] The shared activation write-authority gate — one // implementation, one refusal envelope, per-door wording. See its header for // the posture rule and the measurement behind it (commit 02b41232d). -import { refuseUngrantedActivationWrite, FLOW_ACTIVATION_SUBJECT } from './activation-gate.js'; +import { refuseUngrantedActivationWrite, FLOW_ACTIVATION_SUBJECT, readEffectiveTenancyPosture } from './activation-gate.js'; +// [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate: under a +// wall the environment's metadata, flow definitions included, is the platform +// operator's. The same verdict every `/meta` and `/packages` write door asks. +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; // [#19874] What the run-lifecycle refusal names as the remedy is read off the // SAME inputs the platform-admin derivation reads — the requested posture, the // wall predicate, the declared-administrator variable and the set name — so @@ -631,6 +635,33 @@ function refuseUngrantedFlowWrite( }; } +/** + * [ADR-0131 D3 · D12 items 7–8] The walled refusal at the `/automation` + * definition writes (`POST /`, `PUT /:name`, `DELETE /:name`, + * `POST /:name/clone`). The decision is `walledCatalogWriteVerdict`'s + * (`@objectstack/metadata-core`), never restated: admitted under `single`, with + * no `tenancy` service, for `isSystem` and for the `PLATFORM_ADMIN` rung; + * refused for everyone else under a wall, with this domain's + * `403 PERMISSION_DENIED`. + */ +async function refuseWalledFlowDefinitionWrite( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): Promise { + const ec: any = context?.executionContext; + const verdict = walledCatalogWriteVerdict({ + type: 'flow', + tenancyPosture: await readEffectiveTenancyPosture(deps, context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (verdict.allowed) return undefined; + return { + handled: true, + response: deps.error(verdict.message, FLOW_WRITE_DENY_STATUS, { code: FLOW_WRITE_DENY_CODE }), + }; +} + /** [#13953] The path segment naming the cancel door (ADR-0044's operator verb). */ const RUN_CANCEL_SEGMENT = 'cancel'; /** [#13953] The path segment naming the repair door (#13909's operator verb). */ @@ -2353,6 +2384,21 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (refusal) return refusal; } + // [ADR-0131 D3 · D12 items 7–8] WALLED DEFINITION-WRITE GATE — under + // `group` / `isolated` every organization shares the environment's + // metadata, and a flow definition is registered at environment scope, so + // creating, updating, deregistering or cloning one is the platform + // operator's (ruling A on #23231, record 6145019463). Measured before it: + // an organization's `manage_metadata` holder (rung `TENANT_ADMIN`) created + // a flow here, `200`, and another organization's member read it. Directly + // after the capability gate, for that gate's reasons. The enablement door + // is not judged here: the ADR-0126 §5 activation gate below already + // refuses it to every caller but the operator under a wall. + if (isFlowAuthoringWrite(parts, m) && !isFlowEnablementWrite(parts, m)) { + const refusal = await refuseWalledFlowDefinitionWrite(deps, context); + if (refusal) return refusal; + } + // [ADR-0126 §5] ACTIVATION-WRITE GATE — the install-wide enable/disable // switch needs the platform operator in a walled posture. Placed directly // AFTER the authoring gate and BEFORE the service probe / body checks, for diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 9b8a0ba1c20..ec6d5c731cd 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -146,17 +146,23 @@ function makeQl() { sys_member: [ { user_id: 'u_member', organization_id: 'org_alpha', role: 'member' }, { user_id: 'u_exmember', organization_id: 'org_beta', role: 'member' }, + { user_id: 'u_admin', organization_id: 'org_alpha', role: 'owner' }, ], sys_user: [ { id: 'u_member', email: 'u_member@example.com' }, { id: 'u_exmember', email: 'u_exmember@example.com' }, + { id: 'u_admin', email: 'u_admin@example.com' }, ], sys_user_permission_set: [ { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + // [ADR-0131 D12 items 7–8] The platform operator: under the wall + // the one caller the `/meta` write doors admit. + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, ], }; /** @@ -192,6 +198,7 @@ function makeSessions(): Record { sid_member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: 'org_alpha' }, // ⭐ THE SUBJECT: the claim outlived the membership. sid_exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: 'org_alpha' }, + sid_admin: { id: 'ses_admin', token: 'tok_admin', userId: 'u_admin', activeOrganizationId: 'org_alpha' }, }; } @@ -211,7 +218,7 @@ function authService(sessions: Record) { const TENANCY = { posture: 'isolated' }; -const COOKIE = { member: 'os_session=sid_member', exmember: 'os_session=sid_exmember' } as const; +const COOKIE = { member: 'os_session=sid_member', exmember: 'os_session=sid_exmember', admin: 'os_session=sid_admin' } as const; type Who = keyof typeof COOKIE; // ── The two transports, over the same services ──────────────────────────────── @@ -364,12 +371,26 @@ describe('[#20408] a session claim the resolver DROPPED scopes nothing, on eithe expect(itemsOf(dispatcher.data).map((d: any) => d.name)).toEqual(itemsOf(rest.data).map((d: any) => d.name)); }); - it('PUT /meta/view/lead_all: the dispatcher lands the write where RestServer lands it — env-wide, never in the left organization', async () => { + it('PUT /meta/view/lead_all: [ADR-0131 D12 items 7–8] both transports refuse the ex-member under the wall, and neither saves anything', async () => { const body = { name: 'lead_all', label: 'Rewritten', object: 'lead', viewKind: 'list' }; const d = bootDispatcher(); const r = bootRest(); const dispatcher = await d.call('PUT', 'exmember', '/meta/view/lead_all', {}, body); const rest = await r.call('PUT', 'exmember', '/meta/view/lead_all', {}, body); + // Each transport's own 403 spelling: REST `FORBIDDEN`, the dispatcher + // `PERMISSION_DENIED`. + expect({ status: rest.status, code: rest.code }).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect({ status: dispatcher.status, code: dispatcher.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(r.protocol.saveMetaItem).not.toHaveBeenCalled(); + expect(d.protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('PUT /meta/view/lead_all: the dispatcher lands the operator\'s write where RestServer lands it — env-wide, never in the organization its session names', async () => { + const body = { name: 'lead_all', label: 'Rewritten', object: 'lead', viewKind: 'list' }; + const d = bootDispatcher(); + const r = bootRest(); + const dispatcher = await d.call('PUT', 'admin', '/meta/view/lead_all', {}, body); + const rest = await r.call('PUT', 'admin', '/meta/view/lead_all', {}, body); expect(rest.status).toBe(200); expect(dispatcher.status).toBe(rest.status); const landed = (p: ReturnType) => p.saveMetaItem.mock.calls.map(([req]: any[]) => req.organizationId); diff --git a/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts b/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts index fec22974c79..cff07159a47 100644 --- a/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts +++ b/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts @@ -1,18 +1,22 @@ // 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`. + * [ADR-0131 D3 · D12 items 7–8] The dispatcher's `PUT /meta/:type/:name` asks + * the walled refusal (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) + * right after its capability gate: under `isolated` / `group` an item of ANY + * type is saved only by the platform operator rung or the engine itself (ruling + * A on #23231: the security catalog is no longer a special case). + * `RestServer`'s four write doors ask the same predicate; the booted proof of + * the shared behaviour, including the cross-organization read-back, is + * `wall-environment-metadata-refusal.dogfood.test.ts` (and, for the catalog, + * `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). + * `saveMetaItem`, and the controls (the operator, `isSystem`, `single`, no + * `tenancy` service). The same holds for `POST /meta/_migrate-stored`, the + * install-wide stored-metadata rewrite: nothing reaches + * `migrateStoredMetadata`. */ import { describe, it, expect, vi } from 'vitest'; @@ -21,7 +25,8 @@ 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 migrateStoredMetadata = vi.fn(async () => ({ apply: false, scanned: 0, canonical: 0, pending: 0, rewritten: 0, skipped: 0, failed: 0, rows: [] })); + const protocol = { saveMetaItem, getMetaItem, migrateStoredMetadata }; const services: Record = { protocol }; if (posture) services.tenancy = { posture }; const resolve = (name: string): unknown => services[name] ?? null; @@ -30,7 +35,7 @@ function boot(posture: 'single' | 'isolated' | 'group' | null) { getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve }, }; - return { dispatcher: new HttpDispatcher(kernel as never), saveMetaItem }; + return { dispatcher: new HttpDispatcher(kernel as never), saveMetaItem, migrateStoredMetadata }; } const ctx = (executionContext: any): any => ({ request: {}, environmentId: 'platform', executionContext }); @@ -43,11 +48,14 @@ 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' }, + object: { name: 'plant_widget', label: 'Plant widget', fields: {} }, + view: { name: 'plant_widget.all', label: 'Plant view' }, + flow: { name: 'plant_flow', label: 'Plant flow', type: 'autolaunched', nodes: [], edges: [] }, }; -describe('[ADR-0131 D3] dispatcher PUT /meta — the walled catalog refusal', () => { +describe('[ADR-0131 D12 items 7–8] dispatcher PUT /meta — the walled refusal, for every type', () => { 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 () => { + it(`${posture}: a tenant's save of each type, catalog or not, 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); @@ -71,17 +79,27 @@ describe('[ADR-0131 D3] dispatcher PUT /meta — the walled catalog refusal', () expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); }); - it(`${posture}: CONTROL — a type outside the catalog is not judged`, async () => { + it(`${posture}: CONTROL — the engine itself (\`isSystem\`) saves any type`, async () => { + for (const [type, body] of Object.entries(BODIES)) { + const stack = boot(posture); + await stack.dispatcher.handleMetadata(`/${type}/${body.name}`, ctx({ userId: 'u_system', isSystem: true }), 'PUT', body); + expect(stack.saveMetaItem, type).toHaveBeenCalledTimes(1); + } + }); + + it(`${posture}: CONTROL — the platform operator rung saves a non-catalog type`, async () => { const stack = boot(posture); - await stack.dispatcher.handleMetadata('/view/plant_view', ctx(TENANT_AUTHOR), 'PUT', { name: 'plant_view', label: 'Plant view' }); + await stack.dispatcher.handleMetadata('/view/plant_widget.all', ctx(OPERATOR), 'PUT', BODIES.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('single: CONTROL — the same `manage_metadata` holder saves every type, unchanged', async () => { + for (const [type, body] of Object.entries(BODIES)) { + const stack = boot('single'); + await stack.dispatcher.handleMetadata(`/${type}/${body.name}`, ctx(TENANT_AUTHOR), 'PUT', body); + expect(stack.saveMetaItem, type).toHaveBeenCalledTimes(1); + } }); it('no `tenancy` service behaves like `single` (ADR-0093 D4/D5)', async () => { @@ -98,3 +116,46 @@ describe('[ADR-0131 D3] dispatcher PUT /meta — the walled catalog refusal', () expect(stack.saveMetaItem).not.toHaveBeenCalled(); }); }); + +describe('[ADR-0131 D12 items 7–8] dispatcher POST /meta/_migrate-stored — the walled refusal', () => { + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: a tenant's install-wide rewrite is refused 403 PERMISSION_DENIED naming the posture, preview and apply alike, and nothing is read`, async () => { + for (const body of [{}, { apply: true }]) { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata('/_migrate-stored', ctx(TENANT_AUTHOR), 'POST', body); + expect(res.response?.status).toBe(403); + expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + const message = String(res.response?.body?.error?.message); + expect(message).toContain(`'${posture}' tenancy posture`); + expect(message).toContain("the environment's stored metadata"); + expect(stack.migrateStoredMetadata).not.toHaveBeenCalled(); + } + }); + + it(`${posture}: CONTROL — the platform operator rung and the engine itself run it`, async () => { + for (const caller of [OPERATOR, { userId: 'u_system', isSystem: true }]) { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata('/_migrate-stored', ctx(caller), 'POST', { apply: true }); + expect(res.response?.status, caller.userId).toBe(200); + expect(stack.migrateStoredMetadata, caller.userId).toHaveBeenCalledTimes(1); + } + }); + } + + it('CONTROL — under `single` and with no `tenancy` service the same tenant holder runs it, unchanged', async () => { + for (const posture of ['single', null] as const) { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata('/_migrate-stored', ctx(TENANT_AUTHOR), 'POST', {}); + expect(res.response?.status, String(posture)).toBe(200); + expect(stack.migrateStoredMetadata, String(posture)).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('/_migrate-stored', ctx({ userId: 'u_x', systemPermissions: [] }), 'POST', {}); + expect(res.response?.status).toBe(403); + expect(String(res.response?.body?.error?.message)).toContain('requires the `manage_metadata` capability'); + expect(stack.migrateStoredMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index efde560e4ee..c40313b77e8 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -31,9 +31,8 @@ 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, + // [ADR-0131 D3 · D12 items 7–8] …and, under a wall, the platform operator + // alone: the walled refusal every environment-metadata write door asks. walledCatalogWriteVerdict, } from '@objectstack/metadata-core'; // [#15238] The DECLARED protocol contracts this domain's request literals are @@ -1359,15 +1358,15 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } - // [ADR-0131 D3] The walled catalog refusal, right after the + // [ADR-0131 D3 · D12 items 7–8] The walled 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)) { + // `isolated` / `group` every organization shares the environment's + // metadata, so only the platform operator rung (or the engine + // itself) saves an item, whatever its type (ruling A on #23231: no + // catalog pre-filter). Inert under `single`. Same predicate as + // `RestServer`'s four write doors, the `/packages` and + // `/automation` write doors and the security plugin's data door. + { const ec: any = _context.executionContext; const walled = walledCatalogWriteVerdict({ type, @@ -1965,6 +1964,25 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } + // [ADR-0131 D12 items 7–8] The walled refusal, after the capability gate + // and before the protocol is resolved or a row is read: this rewrites + // every stored row of the environment's metadata, which under + // `isolated` / `group` is the platform operator's (ruling A on #23231). + // A preview is refused too: it is the same install-wide pass, without + // the write. Inert under `single`. Same predicate as `RestServer`'s + // `_migrate-stored` door and this branch's `PUT` above. + { + const walled = walledCatalogWriteVerdict({ + type: 'sys_metadata', + tenancyPosture: await readEffectiveTenancyPosture(deps, _context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!walled.allowed) { + return { handled: true, response: deps.error(walled.message, 403) }; + } + } + const protocol = await resolveProtocol(deps, _context); if (!protocol || typeof protocol.migrateStoredMetadata !== 'function') { return { handled: true, response: deps.error('Stored-metadata migration not supported', 501) }; diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index 627f58a6bcb..33e92db132f 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -247,18 +247,33 @@ describe('[#20515] a removed member\'s organization-scoped manage_metadata no lo }); }); +/** + * [ADR-0131 D12 items 7–8] Under this rig's `isolated` posture the walled + * refusal answers every package write by a caller below the platform operator + * rung, AFTER the capability gate. A caller the capability gate admitted is + * told so by which refusal it gets: the walled one names the posture, the + * capability gate's names the capability. + */ +async function refusedByTheWallNotTheCapabilityGate(answer: { status: number; code: unknown; body: any }): Promise { + expect({ status: answer.status, code: answer.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + const message = String(answer.body?.error?.message ?? ''); + expect(message).toContain("'isolated' tenancy posture"); + expect(message).not.toContain('requires the `manage_metadata` capability'); +} + describe('[#20515] what the rule leaves unchanged', () => { - it('CONTROL · a current member with the same grant and org_alpha active passes: DELETE /packages/:id answers 200', async () => { + it('CONTROL · a current member with the same grant and org_alpha active passes the capability gate: DELETE /packages/:id reaches the walled refusal, and nothing is deleted', async () => { const r = rig(); const answer = await r.call('DELETE', 'member', `/packages/${PKG}`); - expect(answer.status).toBe(200); - expect(r.deleteRequests).toEqual([{ packageId: PKG }]); + await refusedByTheWallNotTheCapabilityGate(answer); + expect(r.deleteRequests).toEqual([]); + expect(r.registry.getPackage(PKG)).toBeDefined(); }); - it('CONTROL · the same current member passes PATCH /packages/:id/disable: 200, and the package is switched off', async () => { + it('CONTROL · the same current member passes the capability gate at PATCH /packages/:id/disable, and the walled refusal keeps the package enabled', async () => { const r = rig(); - expect((await r.call('PATCH', 'member', `/packages/${PKG}/disable`)).status).toBe(200); - expect(switchedOff(r)).toBe(true); + await refusedByTheWallNotTheCapabilityGate(await r.call('PATCH', 'member', `/packages/${PKG}/disable`)); + expect(switchedOff(r)).toBe(false); }); // [ADR-0131 D3/D4] A position's sets are the ones its catalog definition @@ -271,15 +286,20 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(r.deleteRequests).toEqual([]); }); - it('a GLOBAL grant still passes the gate for the removed member — the uninstall proceeds, naming no organization', async () => { + it('a GLOBAL grant still passes the capability gate for the removed member — the walled refusal answers next, below the operator rung', async () => { + const r = rig(); + await refusedByTheWallNotTheCapabilityGate(await r.call('DELETE', 'global', `/packages/${PKG}`)); + expect(r.deleteRequests).toEqual([]); + await refusedByTheWallNotTheCapabilityGate(await r.call('PATCH', 'global', `/packages/${PKG}/disable`)); + expect(switchedOff(r)).toBe(false); + }); + + it('the operator\'s uninstall proceeds naming no organization, and its disable lands — the positive control that makes `switchedOff` false above mean something', async () => { const r = rig(); - const answer = await r.call('DELETE', 'global', `/packages/${PKG}`); - expect(answer.status).toBe(200); + expect((await r.call('DELETE', 'admin', `/packages/${PKG}`)).status).toBe(200); expect(r.deleteRequests).toEqual([{ packageId: PKG }]); - // …and the disable door, which asks no organization, lands — the - // positive control that makes `switchedOff` false above mean something. const d = rig(); - expect((await d.call('PATCH', 'global', `/packages/${PKG}/disable`)).status).toBe(200); + expect((await d.call('PATCH', 'admin', `/packages/${PKG}/disable`)).status).toBe(200); expect(switchedOff(d)).toBe(true); }); diff --git a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts index a5dce100370..e4008c61b12 100644 --- a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts @@ -5,10 +5,13 @@ * organization BEFORE it touched the running registry. * * [ADR-0131 D6/D12] That refusal is RETIRED: an uninstall is environment-wide - * by construction and names no organization, so every caller who passes the - * operator gate — with or without an active organization — uninstalls, and - * `deletePackage` is handed no organization. The history below is kept for - * the rig's sake. + * by construction and names no organization, so every caller the doors admit + * — with or without an active organization — uninstalls, and `deletePackage` + * is handed no organization. [ADR-0131 D12 items 7–8] Under this rig's + * `isolated` posture the doors admit only the platform operator: every caller + * below that rung is refused by the walled refusal BEFORE anything moves, + * which is this file's original lesson (a refused request changes nothing). + * The history below is kept for the rig's sake. * * ## The defect * @@ -35,9 +38,12 @@ * `organizationId` / `allTenants` keys the way * `@objectstack/metadata-protocol`'s `deletePackage` does. * - * Three populations: a current member, a member removed from the organization - * whose session still names it (the resolver drops the claim), and a caller who - * never selected an organization. All three uninstall the same way. + * Three populations below the operator rung: a current member, a member + * removed from the organization whose session still names it (the resolver + * drops the claim), and a caller who never selected an organization. All three + * are refused the same way. The platform operator (an unscoped + * `admin_full_access` grant) uninstalls, with or without an active + * organization, naming none. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -82,23 +88,27 @@ const TABLES: Record = { sys_member: [ { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, { user_id: 'u_exmember', organization_id: BETA, role: 'member' }, + { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, ], sys_user: [ { id: 'u_member', email: 'u_member@example.com' }, { id: 'u_exmember', email: 'u_exmember@example.com' }, { id: 'u_orgless', email: 'u_orgless@example.com' }, + { id: 'u_admin', email: 'u_admin@example.com' }, ], sys_user_permission_set: [ { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, { user_id: 'u_orgless', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, ], }; -type Who = 'member' | 'exmember' | 'orgless'; +type Who = 'member' | 'exmember' | 'orgless' | 'admin' | 'admin_orgless'; const SESSIONS: Record = { member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: ALPHA }, @@ -106,6 +116,9 @@ const SESSIONS: Record; beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); -// ── The subject: every operator uninstalls, naming no organization ─────────── +// ── The subject: the doors refuse below the operator before anything moves ─── const CALLERS: Array<[Who, string]> = [ ['member', 'a current member'], @@ -234,8 +247,27 @@ const CALLERS: Array<[Who, string]> = [ ['orgless', 'a caller who never selected an organization'], ]; -describe('[ADR-0131 D6] DELETE /packages/:id names no organization, whoever asks', () => { +describe('[ADR-0131 D12 items 7–8] DELETE /packages/:id below the operator rung is refused before anything moves', () => { for (const [who, label] of CALLERS) { + it(`${label}: 403 PERMISSION_DENIED naming the posture; the package, its object and its stored rows stay, and deletePackage is never asked`, async () => { + const r = rig(); + expect(await observed(r), 'precondition: the package is installed').toEqual(UNTOUCHED); + const answer = await r.call('DELETE', who, `/packages/${PKG}`); + expect({ status: answer.status, code: answer.body?.error?.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(String(answer.body?.error?.message)).toContain("'isolated' tenancy posture"); + expect(r.deleteRequests).toEqual([]); + expect(await observed(r)).toEqual(UNTOUCHED); + }); + } +}); + +const OPERATORS: Array<[Who, string]> = [ + ['admin', 'the platform operator, with an organization selected'], + ['admin_orgless', 'the platform operator, with no organization selected'], +]; + +describe('[ADR-0131 D6] DELETE /packages/:id names no organization, whoever the doors admit', () => { + for (const [who, label] of OPERATORS) { it(`${label}: 200; the package and its object leave the registry, GET answers 404, and deletePackage is handed no organization`, async () => { const r = rig(); expect(await observed(r), 'precondition: the package is installed').toEqual(UNTOUCHED); diff --git a/packages/runtime/src/domains/packages-vetted-org-source.test.ts b/packages/runtime/src/domains/packages-vetted-org-source.test.ts index 7f0a2051851..f4233ba31ad 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -53,6 +53,13 @@ * [ADR-0131 D6] The doors now thread NO organization into any protocol verb, * vetted or not, so every caller — current member, ex-member, switched * ex-member — reaches the env-wide partition and never an organization's. + * + * [ADR-0131 D12 items 7–8] Under this rig's `isolated` posture every package + * WRITE is the platform operator's, so the three members are refused at the + * write doors before any protocol call, and reach no partition at all; the + * read doors serve them the env-wide partition. The operator (an unscoped + * `admin_full_access` grant, its session naming `org_alpha`) is the admitted + * writer, and it reaches the env-wide partition too. */ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; @@ -179,17 +186,21 @@ function makeQl() { sys_member: [ { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, { user_id: 'u_exmember', organization_id: BETA, role: 'member' }, + { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, ], sys_user: [ { id: 'u_member', email: 'u_member@example.com' }, { id: 'u_exmember', email: 'u_exmember@example.com' }, + { id: 'u_admin', email: 'u_admin@example.com' }, ], sys_user_permission_set: [ { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, ], }; /** Equality plus `$in`, and a loud refusal of every other shape. */ @@ -231,6 +242,8 @@ const SESSIONS: Record = { sid_exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: ALPHA }, // The same person, switched to an organization they ARE in. sid_exmember_beta: { id: 'ses_exmember_beta', token: 'tok_exmember_beta', userId: 'u_exmember', activeOrganizationId: BETA }, + // The platform operator, with `org_alpha` selected. + sid_admin: { id: 'ses_admin', token: 'tok_admin', userId: 'u_admin', activeOrganizationId: ALPHA }, }; function authService() { @@ -256,9 +269,9 @@ function services(): Record { // ── The two HTTP entries into the domain ────────────────────────────────────── -type Who = 'member' | 'exmember' | 'exmember_beta' | 'anonymous'; +type Who = 'member' | 'exmember' | 'exmember_beta' | 'admin' | 'anonymous'; const SID: Record = { - member: 'sid_member', exmember: 'sid_exmember', exmember_beta: 'sid_exmember_beta', anonymous: undefined, + member: 'sid_member', exmember: 'sid_exmember', exmember_beta: 'sid_exmember_beta', admin: 'sid_admin', anonymous: undefined, }; interface Answer { status: number; code?: string; body: any } @@ -388,8 +401,25 @@ describe('[#20477] controls: the rig can tell the organizations apart', () => { describe('[#20477][ADR-0131 D6] no session claim reaches an organization on any /packages door', () => { for (const [transport, entry] of TRANSPORTS) { - for (const who of ['member', 'exmember', 'exmember_beta'] as const) { + for (const who of ['member', 'exmember', 'exmember_beta', 'admin'] as const) { for (const door of DOORS) { + // [ADR-0131 D12 items 7–8] Under the wall a write door admits only + // the operator: a member is refused before any protocol call. + if (door.method !== 'GET' && who !== 'admin') { + it(`${transport} · ${who} · ${door.name}: refused 403 PERMISSION_DENIED before any protocol call, and no organization's rows are read or written`, async () => { + const answer = await entry()(door.method, who, door.path, door.body); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(String(answer.body?.error?.message)).toContain("'isolated' tenancy posture"); + // No protocol call means no partition was read; the + // refusal names the caller's own URL segment (the + // commit id `cmt_alpha`), so the answer's text is not + // a read of anything. + expect(state.calls).toEqual([]); + expect(touched(ALPHA, 'alpha', answer).wrote).toBe(false); + expect(touched(BETA, 'beta', answer).wrote).toBe(false); + }); + continue; + } it(`${transport} · ${who} · ${door.name}: the protocol is handed no organization, and no organization's rows are read or written`, async () => { const answer = await entry()(door.method, who, door.path, door.body); expect(state.calls.map((c) => c.verb)).toContain(door.verb); diff --git a/packages/runtime/src/domains/packages-walled-write-refusal.test.ts b/packages/runtime/src/domains/packages-walled-write-refusal.test.ts new file mode 100644 index 00000000000..c8afc5d0537 --- /dev/null +++ b/packages/runtime/src/domains/packages-walled-write-refusal.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D12 items 7–8 · D11] Every `/packages` write door asks the walled + * refusal (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) right + * after its capability gate: under `isolated` / `group` a package write is the + * platform operator's, whatever the package carries (ruling A on #23231 — W2 + * and W3 refused only a write that touched a position, a permission set or a + * capability). + * + * Pinned here, door by door: a `manage_metadata` holder below the operator + * rung is refused `403 PERMISSION_DENIED` naming the posture, BEFORE any + * protocol call or registry mutation; and the controls — the operator rung, + * `isSystem`, `single`, and no `tenancy` service — are not refused by it. The + * booted proof, with the cross-organization read-back, is + * `wall-environment-metadata-refusal.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG = 'com.example.pkg-a'; + +function boot(posture: 'single' | 'isolated' | 'group' | null) { + const protocol = { + publishPackageDrafts: vi.fn().mockResolvedValue({ success: true, publishedCount: 0, published: [], failed: [] }), + discardPackageDrafts: vi.fn().mockResolvedValue({ success: true, discardedCount: 0 }), + listCommits: vi.fn().mockResolvedValue([{ id: 'c1' }]), + revertCommit: vi.fn().mockResolvedValue({ success: true }), + rollbackToPackageCommit: vi.fn().mockResolvedValue({ success: true }), + reassignOrphanedMetadata: vi.fn().mockResolvedValue({ reassigned: 0 }), + duplicatePackage: vi.fn().mockResolvedValue({ package: { id: 'com.example.pkg-b' } }), + updatePackage: vi.fn().mockResolvedValue({ package: { manifest: { id: PKG } } }), + deletePackage: vi.fn().mockResolvedValue({ deletedCount: 0 }), + installPackage: vi.fn().mockResolvedValue({ package: { manifest: { id: 'com.example.new' } } }), + }; + const registry = { + getAllPackages: vi.fn().mockReturnValue([{ id: PKG, status: 'active' }]), + getPackage: vi.fn().mockReturnValue({ id: PKG, manifest: { id: PKG, name: 'A' } }), + installPackage: vi.fn().mockImplementation((m: any) => ({ id: m.id, manifest: m })), + enablePackage: vi.fn().mockReturnValue({ id: PKG }), + disablePackage: vi.fn().mockReturnValue({ id: PKG }), + uninstallPackage: vi.fn().mockReturnValue(true), + updatePackageManifest: vi.fn().mockReturnValue({ id: PKG }), + }; + const services: Record = { protocol, objectql: { registry } }; + 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), protocol, registry }; +} + +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 SYSTEM = { userId: 'u_system', isSystem: true }; + +/** The thirteen write doors, with the message's subject each one names. */ +const DOORS: Array<{ method: string; path: string; body?: unknown; action: string }> = [ + { method: 'POST', path: '/', body: { manifest: { id: 'com.example.new', name: 'New', version: '1.0.0', type: 'app' } }, action: 'Installing a package' }, + { method: 'PATCH', path: `/${PKG}`, body: { description: 'x' }, action: `Updating package "${PKG}"` }, + { method: 'PATCH', path: `/${PKG}/enable`, body: {}, action: `Enabling package "${PKG}"` }, + { method: 'PATCH', path: `/${PKG}/disable`, body: {}, action: `Disabling package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/publish`, body: {}, action: `Publishing package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/publish-drafts`, body: {}, action: `Publishing the drafts of package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/discard-drafts`, body: {}, action: `Discarding the drafts of package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/commits/c1/revert`, body: {}, action: 'Reverting commit "c1"' }, + { method: 'POST', path: `/${PKG}/rollback`, body: { commitId: 'c1' }, action: `Rolling back package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/revert`, body: {}, action: `Reverting package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/adopt-orphans`, body: {}, action: `Adopting orphaned metadata into package "${PKG}"` }, + { method: 'POST', path: `/${PKG}/duplicate`, body: { targetPackageId: 'com.example.pkg-b' }, action: `Duplicating package "${PKG}"` }, + { method: 'DELETE', path: `/${PKG}`, action: `Deleting package "${PKG}"` }, +]; + +const MUTATORS = ['installPackage', 'enablePackage', 'disablePackage', 'uninstallPackage', 'updatePackageManifest'] as const; + +/** Did the request reach any write the door performs? */ +function anythingWritten(stack: ReturnType): string[] { + const protocolCalls = Object.entries(stack.protocol) + .filter(([name, fn]) => name !== 'listCommits' && (fn as ReturnType).mock.calls.length > 0) + .map(([name]) => `protocol.${name}`); + const registryCalls = MUTATORS.filter((name) => stack.registry[name].mock.calls.length > 0).map((name) => `registry.${name}`); + return [...protocolCalls, ...registryCalls]; +} + +describe('[ADR-0131 D12 items 7–8] the /packages write doors — the walled refusal', () => { + for (const posture of ['isolated', 'group'] as const) { + for (const door of DOORS) { + it(`${posture} · ${door.method} ${door.path}: a tenant holder is refused 403 PERMISSION_DENIED naming the posture, and nothing is written`, async () => { + const stack = boot(posture); + const res = await stack.dispatcher.handlePackages(door.path, door.method, door.body, {}, ctx(TENANT_AUTHOR)); + expect(res.response?.status).toBe(403); + expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + const message = String(res.response?.body?.error?.message); + expect(message.startsWith(`${door.action} is refused.`), message).toBe(true); + expect(message).toContain(`'${posture}' tenancy posture`); + expect(anythingWritten(stack)).toEqual([]); + }); + } + + it(`${posture}: CONTROL — the platform operator rung and the engine itself are not refused by it, at any door`, async () => { + for (const caller of [OPERATOR, SYSTEM]) { + for (const door of DOORS) { + const stack = boot(posture); + const res = await stack.dispatcher.handlePackages(door.path, door.method, door.body, {}, ctx(caller)); + expect(String(res.response?.body?.error?.message ?? ''), `${caller.userId} ${door.method} ${door.path}`) + .not.toContain('tenancy posture'); + expect(res.response?.status, `${caller.userId} ${door.method} ${door.path}`).not.toBe(403); + } + } + }); + } + + it('CONTROL — under `single` and with no `tenancy` service the same tenant holder is not refused by it, at any door', async () => { + for (const posture of ['single', null] as const) { + for (const door of DOORS) { + const stack = boot(posture); + const res = await stack.dispatcher.handlePackages(door.path, door.method, door.body, {}, ctx(TENANT_AUTHOR)); + expect(String(res.response?.body?.error?.message ?? ''), `${posture} ${door.method} ${door.path}`) + .not.toContain('tenancy posture'); + expect(res.response?.status, `${posture} ${door.method} ${door.path}`).not.toBe(403); + } + } + }); + + 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.handlePackages(`/${PKG}`, 'DELETE', undefined, {}, ctx({ userId: 'u_x', systemPermissions: [] })); + expect(res.response?.status).toBe(403); + expect(String(res.response?.body?.error?.message)).toContain('requires the `manage_metadata` capability'); + expect(String(res.response?.body?.error?.message)).not.toContain('tenancy posture'); + expect(anythingWritten(stack)).toEqual([]); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index e87589a5f34..cfc70f8d441 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -32,11 +32,10 @@ import { // `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` — same value it read before, // no re-ruling of the package cohort as a side effect of #7020. import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; -// [#15204 W2] ADR-0131 D3's one predicate, asked by the package delete below -// per stored catalog type, never restated here. -import { SECURITY_CATALOG_TYPES, isSecurityCatalogType, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +// [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate, asked by +// every package write door below, never restated here. +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import { readEffectiveTenancyPosture } from './activation-gate.js'; -import type { IObjectQLEngine } from '@objectstack/spec/contracts'; // [#21727] ADR-0087 D1's protocol handshake and the brand predicate for its // refusal. The install branch asks the predicate ahead of its generic catch, // and its no-protocol fallback runs the same handshake. @@ -416,115 +415,44 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * 422 (and so this gate never becomes an existence oracle of its own). */ /** - * [#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`). + * [ADR-0131 D3 · D12 items 7–8 · #23231] The walled refusal at the `/packages` + * write doors: under a wall every organization shares the environment's + * metadata, and a package is that metadata's unit of authoring, so every + * package write is the platform operator's. + * + * Measured before this refusal (ruling A on #23231, record 6145019463), under + * `isolated` and `group`: an organization's `manage_metadata` holder (rung + * `TENANT_ADMIN`) answered `2xx` at all thirteen write doors — create, the + * manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, + * revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and + * delete — on packages carrying no catalog item, and another organization's + * member read each change (`wall-environment-metadata-refusal.dogfood.test.ts`). + * W2 and W3 had refused these doors only when the items a verb acted on + * included a position, a permission set or a capability (ADR-0131 D3); that + * reading of the items is gone with the special case. + * + * Every write door asks this right after {@link requireManageMetadata} and + * before anything else is read or written. The decision is + * {@link walledCatalogWriteVerdict}'s, never restated: admitted under `single`, + * with no `tenancy` service, for `isSystem` and for the `PLATFORM_ADMIN` rung, + * so the door then behaves exactly as before; refused for everyone else under a + * wall, with this domain's `403` (`PERMISSION_DENIED`). */ async function refuseWalledCatalogPackageWrite( deps: DomainHandlerDeps, context: HttpProtocolContext, /** 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); - const verdictFor = (type: string) => walledCatalogWriteVerdict({ - type, - tenancyPosture, + const verdict = walledCatalogWriteVerdict({ + type: 'package', + tenancyPosture: await readEffectiveTenancyPosture(deps, context), isSystem: ec?.isSystem === true, callerPosture: ec?.posture, }); - if (verdictFor(SECURITY_CATALOG_TYPES[0]!).allowed) return null; - let types: Iterable; - try { - types = await readTypes(); - } catch (e) { - return { handled: true, response: deps.errorFromThrown(e, 500) }; - } - 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(`${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) : []; + if (verdict.allowed) return null; + return { handled: true, response: deps.error(`${action} is refused. ${verdict.message}`, 403) }; } /** @@ -1162,6 +1090,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // registry write only when the protocol service/method is unavailable. if (parts.length === 0 && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, 'Installing a package'); + if (walled) return walled; // [#19328] ⭐ THE DOOR PARSES THE WHOLE BODY — once, here, through // the union `@objectstack/spec` declares for it // (`PackageInstallBodySchema`: the wrapped request, or a bare @@ -1528,6 +1459,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Enabling package "${id}"`); + if (walled) return walled; const pkg = registry.enablePackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; try { @@ -1542,6 +1476,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Disabling package "${id}"`); + if (walled) return walled; // [#7560] ADR-0070: a platform package is not the operator's to // switch off. BEFORE `disablePackage`, and before // `setPackageDisabled` writes the choice to disk — a disable that @@ -1561,6 +1498,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Publishing package "${id}"`); + if (walled) return walled; // [#22113] ADR-0070 D2: a code or installed package is read-only, so // this door refuses it BEFORE `publishPackage`, which would otherwise // snapshot and re-register the package's items as published (measured: @@ -1587,12 +1527,8 @@ 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' }), - ); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Publishing the drafts of package "${id}"`); if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.publishPackageDrafts === 'function') { @@ -1926,12 +1862,8 @@ 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' }), - ); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Discarding the drafts of package "${id}"`); if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.discardPackageDrafts === 'function') { @@ -1974,16 +1906,8 @@ 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), - ); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Reverting commit "${commitId}"`); if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.revertCommit === 'function') { @@ -2004,35 +1928,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // back THROUGH every commit newer than `commitId` (ADR-0067). if (parts.length === 2 && parts[1] === 'rollback' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Rolling back package "${decodeURIComponent(parts[0])}"`); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.rollbackToPackageCommit === 'function') { 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), @@ -2050,12 +1953,8 @@ 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' }), - ); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Reverting package "${id}"`); 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 @@ -2113,15 +2012,8 @@ 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'), - ); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Adopting orphaned metadata into package "${id}"`); if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (!protocol || typeof protocol.reassignOrphanedMetadata !== 'function') { @@ -2153,6 +2045,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Duplicating package "${id}"`); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (!protocol || typeof protocol.duplicatePackage !== 'function') { return { handled: true, response: deps.error('Package duplication not supported', 501) }; @@ -2165,15 +2060,6 @@ 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, @@ -2240,6 +2126,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 1 && m === 'PATCH') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Updating package "${id}"`); + if (walled) return walled; const src = (body?.manifest && typeof body.manifest === 'object' ? body.manifest : body) ?? {}; const patch: { name?: string; description?: string; version?: string } = {}; if (typeof src.name === 'string') patch.name = src.name.trim(); @@ -2279,22 +2168,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 1 && m === 'DELETE') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [ADR-0131 D12 items 7–8] Under a wall, the platform operator's. + const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Deleting package "${id}"`); + if (walled) return walled; // [#7560] ADR-0070: refuse BEFORE `uninstallPackage`, which is the // call that removed a platform package from the running registry // listing (and with it every object the package registers) until // the next restart. const readOnly = requireWritablePackage(deps, qlService, id, 'delete'); if (readOnly) return readOnly; - // [ADR-0131 D6/D12] No organization: an uninstall is environment-wide - // by construction. The gate above checks the `manage_metadata` - // capability (or `isSystem`) and no posture rung, so under a wall an - // 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 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 index 27e3838036b..2c7bdf34f22 100644 --- 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 @@ -19,10 +19,10 @@ export const entry: SemanticMigration = { + '/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', + + '`OS_PLATFORM_OWNER_EMAIL` owner) runs those verbs on catalog-carrying packages. ' + + 'walled-environment-metadata-writes-retired then refuses the same caller those verbs on every ' + + 'package, whatever its drafts, commits or rows carry. A custom package door asks ' + + '`walledCatalogWriteVerdict` (`@objectstack/metadata-core`) 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 ' @@ -36,11 +36,9 @@ export const entry: SemanticMigration = { + '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, ' + + 'commits//revert (also under a URL naming another package), duplicate and, into ' + + 'another package, 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.', + + 'resolved before. 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.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts index 6933ebc27d8..cb7b079ca70 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts @@ -20,10 +20,10 @@ export const entry: SemanticMigration = { + 'positions or permission sets on the runtime dispatcher', replacement: 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' - + '`OS_PLATFORM_OWNER_EMAIL` owner) installs, upgrades, uninstalls and deletes those packages; ' - + 'a package with no catalog item installs, upgrades and uninstalls exactly as before. A custom ' - + 'package door asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) once per catalog ' - + 'type the package declares, after its capability gate', + + '`OS_PLATFORM_OWNER_EMAIL` owner) installs, upgrades, uninstalls and deletes those packages. ' + + 'walled-environment-metadata-writes-retired then refuses the same caller every other package ' + + 'write too, whatever the package carries. A custom package door asks `walledCatalogWriteVerdict` ' + + '(`@objectstack/metadata-core`) 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, including through ' @@ -37,8 +37,7 @@ export const entry: SemanticMigration = { + 'set another organization\'s member holds: the answer is 403 FORBIDDEN, the message names the ' + 'posture, and the member resolves exactly what it resolved before. The same owner\'s uninstall ' + 'of that package answers 403 FORBIDDEN, and DELETE /api/v1/packages/ of a runtime package ' - + 'holding a permission set answers 403 PERMISSION_DENIED; the member keeps the set. The same owner ' - + 'installs, upgrades and uninstalls a package with no catalog item with 200. The platform operator ' - + 'does all of it with 200, and on a single deployment a manage_metadata holder does all of it ' - + 'exactly as before.', + + 'holding a permission set answers 403 PERMISSION_DENIED; the member keeps the set. The platform ' + + 'operator does all of it with 200, and on a single deployment a manage_metadata holder does all ' + + 'of it exactly as before.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-environment-metadata-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-environment-metadata-writes-retired.ts new file mode 100644 index 00000000000..fffa80333f2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.walled-environment-metadata-writes-retired.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D12 items 7–8 and D11 (ruling A on #23231) — a runtime-door +// narrowing with no authorable key: under a walled tenancy posture every +// environment-metadata write admits only the platform operator, whatever its +// type. The security catalog was the first case refused +// (walled-catalog-tenant-writes-retired, walled-catalog-package-writes-retired, +// walled-catalog-package-verbs-retired); this entry stops special-casing it. +// Registered because a caller that wrote objects, views, flows or packages +// before is refused now, and an operator must be told who keeps that authority. +export const entry: SemanticMigration = { + id: 'walled-environment-metadata-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, by a caller below the platform operator, whatever the metadata ' + + 'type: PUT, DELETE, publish and rollback of /meta/:type/:name on @objectstack/rest and PUT on the ' + + 'runtime dispatcher; POST /meta/_migrate-stored, the install-wide stored rewrite, on both ' + + 'transports, preview included; every /api/v1/packages write on the runtime dispatcher (create, the manifest ' + + 'PATCH, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, ' + + 'commits/:commitId/revert, duplicate, adopt-orphans and delete); POST and DELETE of ' + + '/api/v1/marketplace/install-local (@objectstack/cloud-connection); and the /api/v1/automation ' + + 'definition writes (create, update, delete and clone)', + replacement: + 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + + '`OS_PLATFORM_OWNER_EMAIL` owner) authors the environment\'s metadata, in Studio or through a ' + + 'managed package; an organization that needs metadata of its own is given an environment of its ' + + 'own. An organization\'s administrators still assign the catalog\'s positions and permission sets ' + + 'to their own members. A custom door that writes environment metadata asks ' + + '`walledCatalogWriteVerdict` (`@objectstack/metadata-core`) after its capability gate, for every ' + + 'type, with no catalog pre-filter', + reason: + 'ADR-0131 D12 items 7–8: a walled deployment is one environment with one schema and one catalog, ' + + 'the metadata-authoring capability is what headquarters IT holds and plant admins do not, and a ' + + 'plant cannot create a position, a permission set, a capability, a template or any metadata. D11: ' + + 'the rules are identical in every posture and only the wall differs. The environment definition ' + + 'ledger carries no organization column, so before this change a manage_metadata holder scoped to ' + + 'one organization could create or change an object, a view or a flow, publish, roll back or ' + + 'reset the operator\'s items, or install, upgrade, remove and edit packages, and every other ' + + 'organization read the result.', + acceptanceCriteria: + 'On an isolated or group deployment, an organization owner holding manage_metadata inside their ' + + 'own organization saves an object, a view and a flow at /api/v1/meta, edits, publishes, rolls ' + + 'back and resets the operator\'s view, runs POST /api/v1/meta/_migrate-stored, calls each of the ' + + 'thirteen /api/v1/packages write doors, ' + + 'installs, upgrades and uninstalls a package through install-local, and creates, updates, ' + + 'deletes and clones a flow at /api/v1/automation: each answers 403 (FORBIDDEN on the REST /meta ' + + 'doors and install-local, PERMISSION_DENIED on the dispatcher, /packages and /automation), the ' + + 'message names the posture and ADR-0131 D12 items 7–8, and another organization\'s member reads ' + + 'what the operator wrote, unchanged. The platform operator and the engine itself write at every ' + + 'one of those doors with 2xx, and on a single deployment a manage_metadata holder writes exactly ' + + 'as before. Items saved before this change keep their bytes.', +}; 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 index 467bdcac6a9..de1e8511907 100644 --- 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 @@ -3,5 +3,5 @@ "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." + "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131's walled refusal (D3 for the catalog, D12 items 7–8 and D11 for every environment-metadata type): under `group` / `isolated` a write of the environment's metadata, whatever its type, 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. It judges every type alike, so no door pre-filters on the written type before asking it: the `/meta` write doors and `POST /meta/_migrate-stored` on both transports, every `/packages` write door, the `/automation` definition writes, `install-local` and the security plugin's data door ask it; `single` is inert." }