Skip to content

Commit 430e5f5

Browse files
fix(security)!: under a wall every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) (#23330)
Fixes #23231 Clause-②: no (narrowing) Execution of ruling A on #23231 (record 6145019463): under a wall, every environment-metadata write admits only the platform operator and `isSystem`. The one verdict, `walledCatalogWriteVerdict`, stops special-casing the security catalog, and no door pre-filters on the written type before asking it. `single` is unchanged. The Clause-② value is measured: the diff adds no export, no error code and no response field. It removes one (`error.details.catalogItems` on the `install-local` refusal). `check:api-surface` is green. ## Step 1: measured first (BEFORE) **Harness.** A booted `examples/app-crm` stack (`bootStack`, `multiTenant: 'posture-only'`, plus `MarketplaceInstallLocalPlugin`), with the same shape as W, W2 and W3: - Organization A's owner holds an operator-authored set carrying `manage_metadata`, granted scoped to organization A (rung `TENANT_ADMIN`, `isPlatformAdmin: false`). - Organization B's owner reads through `GET /meta`. - Under `single`, A holds the set unscoped (rung `MEMBER`). Measured on this branch at `bae5841bce`, before any code edit. `isolated` and `group` answered identically. | Door (A, below the operator) | `isolated` | `group` | `single` | B reads afterwards (walled) | |:---|:---|:---|:---|:---| | `PUT /meta/object/:name` (create) | 200 | 200 | 200 | A's object | | `PUT /meta/view/:name` (create) | 200 | 200 | 200 | A's view | | `PUT /meta/flow/:name` (create) | 200 | 200 | 200 | A's flow | | `PUT /meta/view/:name` over the operator's view | 200 | 200 | 200 | **A's label** | | `POST /meta/view/:name/publish` of the operator's draft | 200 | 200 | 200 | **the draft, published** | | `POST /meta/view/:name/rollback` of the operator's view | 200 | 200 | 200 | **the old version** | | `DELETE /meta/view/:name` (reset) of the operator's view | 200 | 200 | 200 | **404** | | dispatcher `PUT /meta/:type/:name`, a non-catalog type | admitted | admitted | admitted | (pinned by the then-control in `meta-walled-catalog-refusal.test.ts`) | | `POST /packages` (create) | 201 | 201 | 201 | | | `PATCH /packages/:id`, `/enable`, `/disable`, `POST /publish` | 200 | 200 | 200 | | | `publish-drafts` on the operator's package | 200 | 200 | 200 | **the pending draft** | | `revert`, `discard-drafts` | 200 | 200 | 200 | | | `rollback`, `commits/:commitId/revert` | 200 | 200 | 200 | **the older commit** | | `duplicate`, `adopt-orphans` | 200 | 200 | 200 | | | `DELETE /packages/:id` | 200 | 200 | 200 | **404** | | `install-local` install (a package carrying an object) | 200 | 200 | 200 | **A's object** | | `install-local` upgrade of the operator's package | 200 | 200 | 200 | **A's label** | | `install-local` uninstall of the operator's package | 200 | 200 | 200 | | | `POST /automation` (flow create; not named by the ruling) | 200 | 200 | 200 | **A's flow** | | `POST /meta/_migrate-stored` (preview and apply; both transports; added in patch round 1, seat direction 6154226716) | 200 | 200 | 200 | (an install-wide rewrite of the environment's stored metadata) | On the first harness run, `adopt-orphans` answered 403 under both walls. The environment then held the harness's own catalog orphan, so W3's catalog refusal fired. Once that set was saved into a package, the door answered 200; that second run is the one tabled. **Verdict on the premise.** No named door was already closed. One organization's write reached every other organization at every door, so the refusal is owed at all of them. The data door to the ledger itself does not exist: `POST /data/sys_metadata` answered 405. **The `organization_admin` assignment reading.** On the same harness, organization B's stock owner (`organization_admin`, rung `TENANT_ADMIN`) posted `POST /data/sys_user_permission_set`, under both walls. Every case answered **403 `PERMISSION_DENIED`** (object `sys_user_permission_set`): - the `manage_metadata` set, to a member of organization B; - the same set, to the owner's own user; - a set carrying no capability, as the control. The reason is the grant, not the capability: `organization_admin` is read-only on `sys_user_permission_set` and `sys_user_position` (`default-permission-sets.ts`). So a stock organization admin cannot hand out `manage_metadata`. An organization-scoped holder appears only through an operator's grant: an operator-authored set, a system write, or an operator-granted assignment-table grant or `adminScope` allowlist. This is a reading only, and the door refusal does not depend on it. ## The PM's mechanism hypotheses, measured 1. **Two layers of short-circuit: confirmed, plus a third shape.** - The verdict's own `isSecurityCatalogType` line (`meta-write-capability.ts`). - The REST `walledCatalogRefusal` pre-filter. - The dispatcher's `if (isSecurityCatalogType(type))` wrapper. - The `/packages` helper and `install-local` both asked the verdict for `SECURITY_CATALOG_TYPES[0]` and then refused only if the items read included a catalog type. - Every pre-filter is gone. The two package helpers no longer read items at all. 2. **#22447 closes these writes: disproved.** It retired the organization-scoped `manage_org_presentation` arm. A holder of `manage_metadata` itself passed every door (table above). 3. **The data door is out of reach: confirmed by reading.** `createWalledCatalogDataDoorRefusal` maps only `sys_position`, `sys_permission_set` and `sys_capability` to a type, and calls `next()` for every other object before asking the verdict. A wider verdict reaches no other data. It and the catalog gates in `security-plugin.ts` are not edited (seat 2's region). 4. **Ordinary members writing metadata in a product flow: none found.** - Every door here demands `manage_metadata` first: `metaWriteCapabilityVerdict`, `requireManageMetadata`, `install-local`'s `requireInstallCapability` and the `/automation` `refuseUngrantedFlowWrite`. - Its only stock holder is `admin_full_access` (`ADMIN_FULL_ACCESS_CAPABILITIES`). `organization_admin` carries `manage_org_users`, `setup.access`, `setup.write` and `manage_mailboxes`. - Every registered metadata type is environment metadata: the 28 types of `DEFAULT_METADATA_TYPE_REGISTRY`, from `object` to `skill`. Each is stored in `sys_metadata`, which carries no organization column (ADR-0131 D6/D7). - No user-scoped type exists. ADR-0131 D13 parks personal views as a future user-owned object, not as a `/meta` type. - So the refusal removes nothing an ordinary member could do. Under a wall it removes the organization-scoped `manage_metadata` holder only. 5. **Headquarters IT under `group`.** See the fork-clause reading below. ## The fork clause: what the repo says HQ IT holds - **ADR-0131 D12 item 7:** "The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." It names the capability, not the posture rung. The same item calls every customization "environment-wide and operator-gated (D6)". - **The posture ladder (`posture-ladder.ts`).** `PLATFORM_ADMIN` derives from the platform-admin anchors and never from a held capability. Under `group` and `isolated` the anchor is `OS_PLATFORM_OWNER_EMAIL`, which `environment-variables.mdx` marks "Required under the walled postures". Holding `manage_metadata` never lifts the rung. - **`delegated-administration.mdx`.** It says HQ is not constrained by delegation, and it does not say who holds `manage_metadata`. - **Reference deployments.** No example runs `group`. The only `group` harnesses are the W family dogfood tests, where the platform operator is the seeded admin declared in `OS_PLATFORM_OWNER_EMAIL`. **Reading.** In a stock `group` deployment, HQ IT's `manage_metadata` comes from the platform-admin set, so HQ IT is the operator and keeps authoring. The ADR's own text, though, describes HQ IT as a `manage_metadata` holder without naming the rung. A deployment that gave HQ IT an operator-authored set granted inside the HQ organization has a non-operator HQ IT. Under this PR that HQ IT is refused every metadata write, and its administrators must be declared in `OS_PLATFORM_OWNER_EMAIL`. That is the operational consequence the ruling asked to surface. It is reported on #23231, the PR stays a draft, and `content/docs/deployment/tenancy-modes.mdx` now says it in the deployer's terms. ## What changed - **`@objectstack/metadata-core`: `walledCatalogWriteVerdict`.** - The catalog-only line is gone, so every type is judged alike. - The message names the written type ("a position", "a permission set", "a capability", "a package", or "a 'view' metadata item"), the posture, and "ADR-0131 D3, D12 items 7–8, D11". It points at an environment of the organization's own. - The export's name is unchanged. - **`@objectstack/rest`.** `walledCatalogRefusal` drops its pre-filter. The four `/meta` doors (save, reset, publish, rollback) refuse a non-operator `403 FORBIDDEN` for every type. - **`@objectstack/runtime`.** - The dispatcher `PUT /meta` drops its catalog wrapper and answers `403 PERMISSION_DENIED`. - `refuseWalledCatalogPackageWrite` drops its item reading. All thirteen `/packages` write doors ask it right after `requireManageMetadata`, before any read or write. Its three item readers (`storedRowTypes`, `readCommitRow`, `commitItemTypes`) are deleted. - The `/automation` definition writes (create, update, delete, clone) ask the verdict through the new `refuseWalledFlowDefinitionWrite`. The enablement door keeps the ADR-0126 §5 activation gate. - **`POST /meta/_migrate-stored`** (patch round 1, seat direction 6154226716): - Both transports ask the verdict after their `manage_metadata` gate and before the protocol is resolved or any row is read: `rest-server.ts`, and the dispatcher's `runtime/src/domains/meta.ts`. - A non-operator under a wall is refused for preview and apply alike, `403 FORBIDDEN` / `403 PERMISSION_DENIED`, each in its door's own envelope. - The refusal names its subject as "the environment's stored metadata". That is a new key in the verdict's internal subject map, not a new export. - **`@objectstack/cloud-connection`.** `refuseWalledCatalogPackage` drops its catalog reading. `install-local` install, upgrade and uninstall refuse a non-operator `403 FORBIDDEN`. - **Activation remedies (`activation-gate.ts`).** The flow, action and permission-set remedies recommended a clone or a sibling item. That refusal is answered only under a wall, where those writes are the operator's now (and for permission-set clones, since W). The remedies now name the operator. - **`@objectstack/spec`.** - The ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. - The W2 and W3 entries no longer claim that a catalog-free package is "handled exactly as before". - **Docs.** - `deployment/tenancy-modes.mdx`: a postures-table row, and a section on who writes the environment's metadata and on HQ IT. - `api/metadata-api.mdx`: the `PUT /meta` and `/packages` rules. - `permissions/permission-sets.mdx` and `permissions/capabilities.mdx`. - Census row 55b in `permissions/system-context.mdx`, which now anchors `refuseWalledFlowDefinitionWrite` too. - The platform-checklist clause that quoted the action remedy. - The ADR anchor for `meta-write-capability.ts`, and the changeset `.changeset/23231-walled-environment-metadata-writes.md`. ## Re-judged pins (the repo-wide sweep) A tenant's write under a wall flips from 2xx to 403, so every pin that encoded the 2xx was flipped to assert the refusal and what did not move: - `meta-walled-catalog-refusal.test.ts`: the "a type outside the catalog is not judged" control now asserts a refusal for an object, a view and a flow. It gains `isSystem` and operator controls. - `automation-activation-posture-gate.test.ts`: the refusal no longer recommends a clone. The clone door refuses the tenant under a wall, with the walled sentence, not the activation one. - `packages-vetted-org-source.test.ts`: the members are refused at every write door before any protocol call. The read doors are unchanged. A new operator arm keeps the "no organization is threaded" property for admitted writes. - `packages-uninstall-refuse-before-mutate.test.ts`: the three non-operator callers are refused with nothing moved. The operator, with and without an active organization, uninstalls naming no organization. - `packages-orgless-grants-capability-gate.test.ts`: the controls show that the capability gate admitted the member, and that the walled refusal answered next. The operator is the positive control. - `meta-read-org-scope-parity.test.ts`: both transports refuse the ex-member under the wall. The operator's save lands env-wide on both. - `wall-package-refusal.dogfood.test.ts` (W2) and `wall-package-doors-refusal.dogfood.test.ts` (W3): A's catalog-free CONTROL becomes a refusal pin. A can no longer create packages, so the "other package" arms use an operator-created package. The refusal assertions of the catalog pins are kept verbatim. Message pins assert only what the message names: the posture, the ruling and the subject. ## Tests **Patch round 1 (head `dbe63f841e` = `824228ec35` + a merge of `origin/main` `2868248e5e` + one commit):** - **New pins, one per transport:** - `rest/src/meta-migrate-stored-walled-refusal.test.ts`: 6 cases. Under `isolated` and `group`, preview and apply are refused `FORBIDDEN` / 403, and `migrateStoredMetadata` is never called. The operator, `isSystem`, `single` and no-tenancy controls get 200, and the capability gate still answers first. - The dispatcher's five same cases in `runtime/src/domains/meta-walled-catalog-refusal.test.ts` (19 passed). - The new subject in `meta-write-capability.test.ts` (15 passed). - A walled refusal pin plus operator and `single` controls in the dogfood wall file. - **AFTER for the door:** 403 under `isolated` and `group`, naming the posture and ADR-0131; 200 under `single`. - **Ablation leg 3:** with the dispatcher's new call disabled, exactly its 2 refusal pins go red, and the REST transport's 6 stay green. The restore is proven by blob. - **Re-runs on `dbe63f841e`:** - runtime `src/domains`: 116 files / 2972 passed; - rest `/meta` and posture files: 110 / 2284; - dogfood wall files: 99; - typecheck: 70/70; - narrowed lint: 23 files, 0 / 0; - derived gates: 122 run. 121 exit 0, and `check:platform-checklist` exits 1 on the same 8 ABSENT SYMBOL problems that reproduce on `origin/main`, none in this diff. All runs below are at head `824228ec35`, the merge of `origin/main` `5c9bfb0d3f`, unless noted. - **New pins.** - `wall-environment-metadata-refusal.dogfood.test.ts`: 21 tests over `isolated`, `group` and `single`. It covers every door above, B's read-back, the operator controls and the `single` control. - `packages-walled-write-refusal.test.ts`: the 13 doors × 2 postures, with operator, `isSystem`, `single` and no-tenancy controls. - `automation-walled-definition-write.test.ts`: the 4 writes × 2 postures, with the same controls. - **AFTER, on the BEFORE harness.** - Every walled door above answers 403: `FORBIDDEN` on REST `/meta` and `install-local`, `PERMISSION_DENIED` on `/packages` and `/automation`. - B reads the operator's version, unchanged. - `single` is unchanged, cell for cell. The operator control answers 200. - **Suites.** - `@objectstack/runtime`, the whole package: 374 files, 6,178 passed, 19 skipped, in four runs (domains 116 files, plus 55, 100 and 103). - `@objectstack/rest`, the whole package: 284 files, 6,049 passed, 328 skipped, in two runs. - `@objectstack/metadata-core`: 18 files, 552 passed. - `@objectstack/cloud-connection`: 45 files, 693 passed. - `@objectstack/spec` `src/migrations`: 4 files, 208 passed. - **Dogfood.** - The four wall files: 80 passed. - The eleven other walled dogfood files that write through these doors: 121 passed. They are `automation-toggle-tenant-scope`, `federated-anchor-provenance`, the four `install-local-*` files, `public-form-withdrawal-walled`, `schedule-acting-organization`, `security-catalog-showcase`, `showcase-public-form-redirect` and `showcase-public-form-walled-intake`. This run was before the merge, at `733500eb11`. - **Typecheck.** `turbo run typecheck` for metadata-core, rest, runtime, cloud-connection, spec and dogfood: 71 of 71 tasks green. Runtime's `check:test-typecheck` covers its test files. - **Lint.** It is narrowed, with all three pieces of evidence: - The population: `eslint.config.mjs` applies to `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. - The count: `eslint --no-inline-config --format json` over the 22 changed TS files gives 22 files, 0 errors and 0 warnings. - The invariance: the config never enables type-aware linting (no `parserOptions.project`, per its own note), so this diff cannot move a verdict on an untouched file. - **Ablation, verdict leg.** - The catalog-only line was put back through `ablation-replace.mjs`: anchor 1 → 0, blob `1fc932c81c` → `a6632971b2`. - metadata-core was rebuilt. `ablation-dist-preflight` found the marker in `dist/index.cjs` and `dist/index.js`. - Unit: 5 red, 10 green. Dogfood: all 16 refusal pins red (8 × 2 postures), with the 2 guards, 2 operator controls and the `single` control green. - Restore: blob equals HEAD, `git diff HEAD` is empty, the rebuild shows the marker absent from all 12 dist files, and the tree is clean. - **Ablation, REST pre-filter leg.** - The first attempt was a no-op: the replacement contained the anchor, and the tool refused it (anchor 1 → 1) and restored. Nothing ran. - The second attempt put a catalog-only pre-filter in `walledCatalogRefusal`: anchor 1 → 0, blob `94317088f7` → `1512e81a2c`. rest was rebuilt, and the marker was found in `dist/index.cjs` and `dist/index.js`. - Dogfood: exactly the 10 REST `/meta` pins red (5 × 2 postures). The `/packages`, `install-local` and `/automation` pins and every control stayed green (11). - Restore: blob equals HEAD, the rebuild shows the marker absent, and the tree is clean. - **Gates.** `dispatch-gates --commands` derives 122 commands; `--ran` accounts for all 122, 0 not measured. - 121 exit 0. `check:query-options-erasure` timed out under contention and passed on a rerun. `check:skill-examples` and `check:dual-build-cjs-loads` passed once the packages they read were built. - `check:platform-checklist` exits 1 with 8 ABSENT SYMBOL problems in `access-security.json`, `attachments-storage.json` and `search.json`. The identical 8 reproduce on `origin/main` `404fc477e5`. This diff's checklist edit (`api-backend.json`) is not among them. - The battery started at the merge commit `e7fdcc48e2`. It finished on `824228ec35`, whose only change is a test budget in the new dogfood file. The first 60 commands were rerun on `824228ec35` (see the report on #23231). ## Acceptance notes - **Growth beyond the claim's file surface, named.** - `packages/runtime/src/domains/automation.ts`: the `/automation` definition writes. These doors were measured (200, and B reads A's flow), and leaving them open would make this PR's claim false. The bounded in-place fix holds: same defect class, the same one-call shape, no other claim on the file, the same gate family. - `activation-gate.ts`: its remedies were made false by this change. - The W2 and W3 migration entries, made false by this change. - `deployment/tenancy-modes.mdx`. - The platform-checklist clause. - `@objectstack/cloud-connection` keeps its `@objectstack/objectql` dependency, which W2 added for the catalog reading this PR removes. No import of it remains. The dependency, the lockfile entry and the vitest alias are left as they are. - The verdict keeps the name `walledCatalogWriteVerdict`, as do `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite` and `refuseWalledCatalogPackage`. The security plugin's data door (seat 2's region) imports the export under that name. - The data door is untouched. Since stage 8 retired the catalog row objects, it answers for no registered object. --- _Generated by [Claude Code](https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 777eed2 commit 430e5f5

31 files changed

Lines changed: 1562 additions & 554 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/cloud-connection': minor
6+
'@objectstack/spec': minor
7+
---
8+
9+
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)
10+
11+
Clause-②: no (narrowing)
12+
13+
<!-- adr-0087: registered walled-environment-metadata-writes-retired -->
14+
15+
**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.
16+
17+
**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.
18+
19+
**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):
20+
21+
- `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`).
22+
- 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.
23+
- `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`.
24+
- `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.
25+
- `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.
26+
- 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.
27+
- 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.
28+
29+
**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.
30+
31+
**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.

‎content/docs/api/metadata-api.mdx‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ Get a specific metadata item with optional ETag caching.
5757

5858
Create or update a metadata item.
5959

60+
Saving requires the `manage_metadata` capability. Under a `group` or `isolated`
61+
tenancy posture it also requires the platform operator, for every type: the
62+
environment's metadata is shared by every organization (ADR-0131 D12 items 7–8,
63+
D11), so a `manage_metadata` holder inside one organization is refused
64+
`403 FORBIDDEN` naming the posture. The same holds for `DELETE` (reset),
65+
`POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback`, and for
66+
`POST /meta/_migrate-stored`, the install-wide rewrite of the stored metadata,
67+
preview included.
68+
6069
Creates are gated per type: `MetadataTypeRegistryEntry.allowRuntimeCreate` decides
6170
whether a **brand-new** name may be minted under that type at all, separately from
6271
whether an item that already exists may be overlaid (`allowEnvironmentOverlay`). A type that
@@ -123,22 +132,17 @@ Get package details.
123132

124133
### `DELETE /packages/:id`
125134

126-
Uninstall a package. Under a `group` or `isolated` tenancy posture, a package
127-
holding positions or permission sets is the platform operator's to delete: any
128-
other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3).
129-
130-
The package's other write verbs follow the same rule under a wall, judged by
131-
the items each one acts on:
132-
- `POST /packages/:id/publish-drafts`, `POST /packages/:id/discard-drafts` and
133-
`POST /packages/:id/revert`, by the package's pending drafts;
134-
- `POST /packages/:id/rollback`, by the commits it would undo;
135-
- `POST /packages/:id/commits/:commitId/revert`, by that commit's own items,
136-
whatever package the URL names;
137-
- `POST /packages/:id/duplicate`, by the source's published items it copies;
138-
- `POST /packages/:id/adopt-orphans`, by the environment's orphaned rows.
139-
140-
When those items include a position, a permission set or a capability, only the platform
141-
operator may run the verb.
135+
Uninstall a package.
136+
137+
Under a `group` or `isolated` tenancy posture every `/packages` write is the
138+
platform operator's (ADR-0131 D12 items 7–8, D11): install (`POST /packages`),
139+
the manifest `PATCH`, enable, disable, publish, `publish-drafts`,
140+
`discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`,
141+
`adopt-orphans` and this delete. Any other caller, a `manage_metadata` holder
142+
inside one organization included, is refused `403 PERMISSION_DENIED` naming the
143+
posture, before anything is read or written, whatever the package carries.
144+
Reads are unchanged. Under `single` every `manage_metadata` holder writes as
145+
before.
142146

143147
### `PATCH /packages/:id/enable`
144148

‎content/docs/deployment/tenancy-modes.mdx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ fallback **input** to posture resolution. See
3737
| Enterprise `@objectstack/organizations` | not needed | **required** | **required** |
3838
| Default organization | one bootstrapped "Default Organization" | owned by the enterprise runtime | owned by the enterprise runtime |
3939
| Org-management UI (create / switch org) | hidden | shown | shown |
40+
| Who writes the environment's metadata | any `manage_metadata` holder | the platform operator only | the platform operator only |
4041

4142
Only the wall's **predicate** differs between `group` and `isolated`. Everything
4243
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
4647
organization under `isolated`, an empty membership set under `group` — fails
4748
closed to zero rows rather than falling through.
4849

50+
### Under a wall, the environment's metadata is the platform operator's
51+
52+
A walled deployment is one environment with one schema and one catalog
53+
(ADR-0131 D12 items 7–8, D11). Its metadata (objects, fields, views, flows,
54+
apps, dashboards, translations, packages, positions, permission sets,
55+
capabilities, every type) carries no organization, so a write lands for every
56+
organization at once. Under `group` and `isolated` only the platform operator
57+
(the `PLATFORM_ADMIN` rung: an address declared in `OS_PLATFORM_OWNER_EMAIL`)
58+
and the engine's own writes (seeders, installers, migrations) write it. Holding
59+
`manage_metadata` inside an organization does not admit a write: the `/meta`
60+
write doors (save, reset, publish, rollback), the install-wide stored rewrite
61+
(`POST /meta/_migrate-stored`), every `/packages` write,
62+
`install-local` install, upgrade and uninstall, and the `/automation`
63+
definition writes refuse such a caller `403`, naming the posture.
64+
65+
- **Headquarters IT** authors the environment's metadata as the platform
66+
operator: declare its administrators in `OS_PLATFORM_OWNER_EMAIL`. A
67+
`manage_metadata` grant scoped to an organization authors nothing under a wall.
68+
- **An organization's administrators assign**: they place their own members in
69+
the catalog's positions and permission sets, and manage their organization's
70+
data. They define nothing.
71+
- **An organization that needs metadata of its own** is given an environment of
72+
its own.
73+
74+
Under `single` the organization is the environment, and every `manage_metadata`
75+
holder writes as before.
76+
4977
`single` is not "multi-org with the walls left standing": Layer 0 contributes no
5078
filter at all, and the platform's own tenant-scoped RLS policies are stripped.
5179
Policies **you** authored that mention `current_user.organization_id` are

‎content/docs/permissions/capabilities.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,10 @@ result is an unreferenced grant target sitting in the live authorization
245245
namespace. `job` and `agent` carry the same pair for the same reason.
246246

247247
Under a `group` or `isolated` tenancy posture, a caller below the platform
248-
operator reads a different refusal, with the same `403` status: the security
249-
catalog refusal (ADR-0131 D3), whose message names the posture, with the code
250-
`FORBIDDEN` on the REST door and `PERMISSION_DENIED` on the runtime dispatcher.
248+
operator reads a different refusal, with the same `403` status: the walled
249+
refusal every environment-metadata write takes there (ADR-0131 D3, D12 items
250+
7–8), whose message names the posture, with the code `FORBIDDEN` on the REST
251+
door and `PERMISSION_DENIED` on the runtime dispatcher.
251252
It runs before the code-only refusal, so the block above is the answer the
252253
platform operator reads, and every caller under `single`.
253254

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -356,13 +356,10 @@ same request (no ghost grants); the uninstall response reports the revocation
356356
under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers
357357
the route, and it nests the protocol's uninstall report under `persisted`).
358358
Environment-authored sets and other packages' rows survive. Under a `group` or
359-
`isolated` tenancy posture, removing a package that carries positions or
360-
permission sets is the platform operator's (ADR-0131 D3): that delete, and an
361-
`install-local` install, upgrade or uninstall of such a package, refuse any
362-
other caller `403` before anything changes. So do the package verbs that
363-
publish, discard, roll back, revert, duplicate or adopt catalog items
364-
(`publish-drafts`, `discard-drafts`, `revert`, `rollback`,
365-
`commits/:commitId/revert`, `duplicate`, `adopt-orphans`).
359+
`isolated` tenancy posture every package write is the platform operator's
360+
(ADR-0131 D3, D12 items 7–8): the delete, an `install-local` install, upgrade
361+
or uninstall, and every other `/packages` write verb refuse any other caller
362+
`403` before anything changes, whatever the package carries.
366363

367364
## One authoritative store — the definition (ADR-0094 D1, ADR-0131 D3)
368365

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ One of the largest consumers of the flag;
184184
| 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` |
185185
| 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` |
186186
| 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` |
187-
| 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` |
187+
| 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` |
188188
| 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` |
189189
| 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` |
190190
| 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` |

0 commit comments

Comments
 (0)