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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/23231-walled-environment-metadata-writes.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered walled-environment-metadata-writes-retired -->

**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.
36 changes: 20 additions & 16 deletions content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
28 changes: 28 additions & 0 deletions content/docs/deployment/tenancy-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
11 changes: 4 additions & 7 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading
Loading