Repository navigation
Commit 430e5f5
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
File tree
- .changeset
- content/docs
- api
- deployment
- permissions
- docs/qa/platform-checklist/areas
- packages
- cloud-connection/src
- metadata-core/src
- qa/dogfood/test
- rest/src
- runtime/src/domains
- spec/src/migrations/entries/semantic
- scripts/adr-anchors
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
60 | 69 | | |
61 | 70 | | |
62 | 71 | | |
| |||
123 | 132 | | |
124 | 133 | | |
125 | 134 | | |
126 | | - | |
127 | | - | |
128 | | - | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
133 | | - | |
134 | | - | |
135 | | - | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
141 | | - | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
142 | 146 | | |
143 | 147 | | |
144 | 148 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| |||
46 | 47 | | |
47 | 48 | | |
48 | 49 | | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
49 | 77 | | |
50 | 78 | | |
51 | 79 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
249 | | - | |
250 | | - | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
251 | 252 | | |
252 | 253 | | |
253 | 254 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
356 | 356 | | |
357 | 357 | | |
358 | 358 | | |
359 | | - | |
360 | | - | |
361 | | - | |
362 | | - | |
363 | | - | |
364 | | - | |
365 | | - | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
366 | 363 | | |
367 | 364 | | |
368 | 365 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
187 | | - | |
| 187 | + | |
188 | 188 | | |
189 | 189 | | |
190 | 190 | | |
| |||
0 commit comments