Skip to content

Commit dbe63f8

Browse files
committed
fix(security)!: under a wall the install-wide stored-metadata rewrite is the platform operator's too (ADR-0131 D12 items 7-8)
POST /meta/_migrate-stored asks walledCatalogWriteVerdict on both transports, after its manage_metadata gate and before any row is read: 403 FORBIDDEN on RestServer, 403 PERMISSION_DENIED on the dispatcher, preview and apply alike. single, the operator rung and isSystem are unchanged. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2868248 commit dbe63f8

13 files changed

Lines changed: 261 additions & 26 deletions

‎.changeset/23231-walled-environment-metadata-writes.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@ Clause-②: no (narrowing)
1414

1515
**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.
1616

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, 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.
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.
1818

1919
**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):
2020

2121
- `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`).
2222
- 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.
2323
- `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`.
2424
- `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-
- Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors), 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.
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.
2627
- 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.
2728

28-
**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. `POST /meta/_migrate-stored` keeps its own `manage_metadata` gate.
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.
2930

3031
**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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ tenancy posture it also requires the platform operator, for every type: the
6262
environment's metadata is shared by every organization (ADR-0131 D12 items 7–8,
6363
D11), so a `manage_metadata` holder inside one organization is refused
6464
`403 FORBIDDEN` naming the posture. The same holds for `DELETE` (reset),
65-
`POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback`.
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.
6668

6769
Creates are gated per type: `MetadataTypeRegistryEntry.allowRuntimeCreate` decides
6870
whether a **brand-new** name may be minted under that type at all, separately from

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ organization at once. Under `group` and `isolated` only the platform operator
5757
(the `PLATFORM_ADMIN` rung: an address declared in `OS_PLATFORM_OWNER_EMAIL`)
5858
and the engine's own writes (seeders, installers, migrations) write it. Holding
5959
`manage_metadata` inside an organization does not admit a write: the `/meta`
60-
write doors (save, reset, publish, rollback), every `/packages` write,
60+
write doors (save, reset, publish, rollback), the install-wide stored rewrite
61+
(`POST /meta/_migrate-stored`), every `/packages` write,
6162
`install-local` install, upgrade and uninstall, and the `/automation`
6263
definition writes refuse such a caller `403`, naming the posture.
6364

‎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 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) 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` |
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` |

‎packages/metadata-core/src/meta-write-capability.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe('metaWriteCapabilityVerdict — who is admitted', () => {
104104
describe('[ADR-0131 D12 items 7–8] walledCatalogWriteVerdict — under a wall the environment\'s metadata is the platform operator\'s', () => {
105105
const TENANT = { isSystem: false, callerPosture: 'TENANT_ADMIN' };
106106
/** Catalog and non-catalog types alike, plus the package doors' subject. */
107-
const TYPES = [...SECURITY_CATALOG_TYPES, 'object', 'view', 'flow', 'app', 'dashboard', 'translation', 'package'];
107+
const TYPES = [...SECURITY_CATALOG_TYPES, 'object', 'view', 'flow', 'app', 'dashboard', 'translation', 'package', 'sys_metadata'];
108108

109109
it('refuses a non-operator write of every type under `isolated` and `group`', () => {
110110
for (const tenancyPosture of ['isolated', 'group'] as const) {
@@ -127,6 +127,7 @@ describe('[ADR-0131 D12 items 7–8] walledCatalogWriteVerdict — under a wall
127127
position: 'a position',
128128
capability: 'a capability',
129129
package: 'a package',
130+
sys_metadata: "the environment's stored metadata",
130131
view: "a 'view' metadata item",
131132
flow: "a 'flow' metadata item",
132133
};

‎packages/metadata-core/src/meta-write-capability.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,17 @@
3535
* ── What deliberately does NOT consult this predicate ─────────────────────
3636
*
3737
* - `POST /meta/_migrate-stored` (both transports): an install-wide stored-
38-
* metadata rewrite, gated on `manage_metadata` by its own door.
38+
* metadata rewrite, gated on `manage_metadata` by its own door (and, under
39+
* a wall, by the walled predicate below).
3940
* - Every non-`/meta` `manage_metadata` gate (automation flow authoring,
4041
* package management, activation toggles, datasource admin).
4142
* - The read path, which carries no capability gate (ADR-0106 masking is
4243
* the read-side posture).
4344
*
4445
* Under a wall, the doors that write the environment's metadata ask a second
4546
* predicate after their capability gate, {@link walledCatalogWriteVerdict}:
46-
* the `/meta` item write doors, the `/packages` write doors, the `/automation`
47-
* definition writes and `install-local`.
47+
* the `/meta` item write doors, `POST /meta/_migrate-stored`, the `/packages`
48+
* write doors, the `/automation` definition writes and `install-local`.
4849
*
4950
* ── Refusal messages (#7450) ──────────────────────────────────────────────
5051
*
@@ -130,13 +131,16 @@ export function isSecurityCatalogType(type: string): boolean {
130131
/**
131132
* The subject the refusal names, per written type: the catalog's own nouns, a
132133
* package for the package doors (which act on a package's items, whatever
133-
* their types), and any other metadata type by its name.
134+
* their types), the whole stored ledger for the install-wide rewrite
135+
* (`POST /meta/_migrate-stored`, which rewrites every `sys_metadata` row), and
136+
* any other metadata type by its name.
134137
*/
135138
const WRITTEN_SUBJECT: Readonly<Record<string, string>> = {
136139
position: 'a position',
137140
permission: 'a permission set',
138141
capability: 'a capability',
139142
package: 'a package',
143+
sys_metadata: 'the environment\'s stored metadata',
140144
};
141145

142146
/**
@@ -175,7 +179,8 @@ export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN';
175179
* no door pre-filters on the written type before asking it. Every door that
176180
* writes the environment's metadata asks it after its own capability gate: the
177181
* `/meta` item write doors on both transports (save, reset, publish,
178-
* rollback), the `/packages` write doors, the `/automation` definition writes,
182+
* rollback), `POST /meta/_migrate-stored` on both transports, the `/packages`
183+
* write doors, the `/automation` definition writes,
179184
* `install-local` (install, upgrade, uninstall), the permission-set clone
180185
* door and the security plugin's data door on the catalog objects. Under
181186
* `single` it is inert: the organization is the environment, and the
@@ -196,8 +201,9 @@ export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN';
196201
*/
197202
export function walledCatalogWriteVerdict(input: {
198203
/**
199-
* The metadata type written (`package` at the package doors). It names the
200-
* refusal's subject and never decides the verdict.
204+
* The metadata type written (`package` at the package doors,
205+
* `sys_metadata` at the stored-metadata rewrite). It names the refusal's
206+
* subject and never decides the verdict.
201207
*/
202208
type: string;
203209
/** The deployment's EFFECTIVE tenancy posture; `undefined` when no `tenancy` service answers. */

0 commit comments

Comments
 (0)