You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 06cc45d
Browse filesBrowse the repository at this point in the historyBrowse files
fix(security)!: under a wall the security catalog is the platform operator's — tenants are refused catalog writes and may only assign (ADR-0131 D3) (#22895)
Part of #15204
Clause-②: yes (narrowing)
Stage W of #15204 (claim amendment 6112750171): the walled catalog
refusal. This PR builds the refusal ADR-0131 D3 names. The C3 card's
seeder and catalog-object retirement stays open on #15204 and is not
part of this PR.
## The ruling this executes
ADR-0131 D3, verbatim: "Shared-DB multi-tenant (`group` / `isolated`):
tenant admins cannot create or edit catalog items; the operator defines
the catalog for every tenant (managed packages, Studio). Creation
through Setup or the API is refused with a message naming the posture
and the capability." ADR-0131 D7 adds that the catalog pages are
"read-only under a wall". The server refuses whatever the UI shows. This
is the refusal D3 names, not a new gate. Catalog writes stay with
`manage_metadata` holders under `single` (#22621 → A, unchanged). Under
a wall the authority is the platform operator's (#22801 → A).
## Measured first, on `main` at `40bc51f`
The harness was a booted `examples/app-crm` stack (`bootStack`,
`multiTenant: 'posture-only'`, which gives a real non-degraded posture).
It had two organizations created over `POST /auth/organization/create`.
Organization A's owner was granted a set carrying `manage_metadata`,
scoped to organization A. That owner is not the platform operator
(`isPlatformAdmin: false`). Organization B's member held an environment
set, `w_shared`, scoped to organization B.
| Door | `isolated` | `group` |
|:---|:---|:---|
| A: `PUT /meta/permission/w_shared` (adds `manage_users`) | 200 | 200 |
| B's next resolution (`resolveUserAuthzGrants`) | **gains
`manage_users`** | **gains `manage_users`** |
| A: `PUT /meta/position/:name` and `PUT /meta/permission/:name`
(create) | 200 | 200 |
| A: `DELETE /meta/permission/:name` | 200 | 200 |
| A: `PUT /meta/capability/:name` | 403 (`capability` is code-only) |
403 |
| A: `POST /security/_activation/permission/w_shared` | 403
(operator-only already) | 403 |
| A with CRUD on `sys_permission_set`: `POST /data/sys_permission_set` |
201, lands in the **environment ledger** | n/a |
| A with CRUD on `sys_position`: `POST /data/sys_position` | 201, an
organization row with no definition | n/a |
| A: `POST /data/sys_user_permission_set` (assignment) | 201 | n/a |
**Cross-tenant write: yes.** One organization's `PUT /meta` changed what
another organization's members resolve. The precondition is that a
principal below the operator rung holds `manage_metadata`. A stock
`organization_admin` does not hold it, and the anti-escalation deny in
`default-permission-sets.ts` refuses that admin the catalog tables at
the data door. The data door's edit of an existing environment set
answered 404, because the wall hides the organization-less projection
row. So the data door created environment sets but did not reach another
organization's set.
## What changed
- **One predicate:** `walledCatalogWriteVerdict` in
`@objectstack/metadata-core`, beside `metaWriteCapabilityVerdict`. Under
`group` / `isolated`, a write of a `position`, `permission` or
`capability` is admitted only for `isSystem` or the `PLATFORM_ADMIN`
posture rung. The activation gate already reads that rung for the same
reason. It is derived from the platform-admin anchors, never from a held
capability. Under `single`, or with no `tenancy` service, the predicate
is inert. URL spellings are folded through `canonicalMetaUrlType`, the
same fold `saveMetaItem` applies, so `/meta/permissions/x` is judged as
`permission`.
- **The `/meta` doors** ask it right after the capability gate. On
`RestServer` that is save, reset, publish and rollback
(`walledCatalogRefusal`), answering `403 FORBIDDEN`. On the runtime
dispatcher it is `PUT`, answering `403 PERMISSION_DENIED`. The posture
is read only for a catalog type. The dispatcher reads it through
`readEffectiveTenancyPosture`, factored out of the activation gate
unchanged. REST reads it through the existing
`resolveAnonymousFormTenancy` wiring, so the posture and the protocol
come from one kernel. A tenancy service that is registered but cannot
answer is a 503, never a permit.
- **The data door:** `walled-catalog-data-door.ts` (`plugin-security`),
a middleware on `sys_position`, `sys_permission_set` and
`sys_capability`. It runs inside the security middleware and before the
permission-set write-through, so a refused write is never translated
into a metadata save. Assignments (`sys_user_position`,
`sys_user_permission_set`) are not judged.
- **ADR-0087:** the semantic entry
`walled-catalog-tenant-writes-retired`. The changeset carries FROM → TO.
- **Docs:** system-context census row 55b, with the counts regenerated.
### Why not `saveMetaItem`
The amendment named "the metadata door's write path, so every door that
saves through `saveMetaItem` inherits it". Measured: `saveMetaItem`
receives no caller principal, only an `actor` string. It cannot tell the
operator rung from a tenant without a contract change on every caller.
So the predicate sits where the caller is known: each door, after its
capability gate. That is the activation gate's two-step shape
(`refuseUngrantedActivationAuthoring`, then
`refuseUngrantedActivationWrite`). It is one predicate with one
sentence, and no door restates it.
## The clone door (#22881, stage CD)
CD landed first (`45e206e4`), and this PR merged it in (`d2a6934e5`).
The clone door creates environment permission sets, so this PR adds the
same call there:
- `permission-set-clone.ts` asks `walledCatalogWriteVerdict({ type:
'permission', ... })` right after `metaWriteCapabilityVerdict`, with the
posture read through `readEffectiveTenancyPosture`.
- The call comes before any catalog read, so a refused caller learns
nothing about the source or the target name. The answer is `403
PERMISSION_DENIED`, the dispatcher `PUT`'s envelope.
- Pins:
- `permission-set-clone.test.ts`: `isolated` and `group` are refused
with nothing saved; the operator rung is admitted; a `single` control;
the capability gate still runs first.
- Two dogfood legs: organization A's clone answers 403 with no
definition saved, and an operator control answers 200.
## Re-judged pins
- `position-write-through.test.ts`: the old walled pin was "an
organization administrator's create lands its organization row, and no
definition". That is the behaviour D3 refuses, so it now pins the
refusal. The promotion pin seeds the pre-existing row-only position with
a system write: that population is #15196 Q3 = A's.
- `builtin-positions.boot.test.ts`: under a wall, the golden's "control:
create a position row" is now `403 PERMISSION_DENIED`. The assignment
cases do not move.
## Tests (first round at `de89dd413`; patch round 1 at code head
`26c70abf8`; every later commit up to `637f1269b` is text only: docs,
the changeset, the spec entry's strings, one `.describe()` string and
its regenerated reference page)
- `metadata-core` 17 files / 386 tests, plus 8 new predicate cases.
`plugin-security` 198 files / 4057 passed. `rest` 278 files / 5542
passed. `runtime` 355 files / 5831 passed. `spec` `src/migrations` 205
passed. Build and typecheck green for metadata-core, rest, runtime and
plugin-security.
- New pins:
- `wall-catalog-refusal.dogfood.test.ts`: `isolated` and `group`, plus a
`single` control, 13 tests.
- `walled-catalog-data-door.test.ts`: real `ObjectQL` and
`SecurityPlugin`, 12 tests.
- `meta-walled-catalog-refusal.test.ts`: the dispatcher, 11 tests.
- **Ablation, REST door:** `walledCatalogRefusal` was forced to admit,
`rest` rebuilt, and the marker confirmed in `dist/` by
`ablation-dist-preflight`. The 4 `/meta` dogfood pins went red, on both
postures. The data-door and control pins stayed green, as expected.
Restored to the `HEAD` blob and rebuilt; the marker is absent from
`dist/`.
- **Ablation, data door:** the middleware registration was removed. 6
walled refusal pins went red, and the assignment, operator and `single`
controls stayed green. Restored to the `HEAD` blob.
- `dispatch-gates --ran` on `5354b1601`: 125 derived, 124 run green, 1
not measured. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT
MET) because packages outside this diff are unbuilt here.
- **Ablation, clone door:** the `if (!walled.allowed)` refusal was made
unreachable. The `isolated` and `group` clone refusal pins went red (2
failed, 20 passed). Restored to the `HEAD` blob.
- `permission-set-clone.test.ts` with
`meta-walled-catalog-refusal.test.ts`: 33 passed. The dogfood files
`wall-catalog-refusal` and `permission-set-clone-door`: 21 passed.
`check:system-context-census`: OK, 129 sites (the clone door's
`isSystem` read moved it from 128).
- Dogfood suite in full on the merged head: 251 files passed, 1 skipped
(the report of patch round 1 on #15204, 6114447595).
## Acceptance notes
- **Not filed (no reach measured):** under a wall, an
organization-scoped `manage_metadata` holder also writes the
environment's objects, views and flows through `/meta`. D3 governs only
the catalog. No card is filed, because only the catalog was measured.
- **Not covered here:** under a wall, ADR-0090 D12's delegated
`authorEnvironmentSets` scope can no longer author environment sets at
the data door. D3 refuses tenants catalog creation, and a delegate is a
tenant. The delegated assignment and binding paths are untouched.
`sys_position_permission_set` is not judged: under ADR-0131 D3 its rows
bind nothing, and it retires with the catalog objects.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability.").
16
+
17
+
**Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve.
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 /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO**`403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability.
22
+
- The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below.
23
+
-`POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO**`403 PERMISSION_DENIED`, before the source is read.
24
+
- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO**`403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched.
25
+
26
+
**Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall.
27
+
28
+
**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`.
Copy file name to clipboardExpand all lines: content/docs/permissions/delegated-administration.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -58,7 +58,7 @@ Writes to the governed tables (`sys_user_position`,
58
58
| Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ |
59
59
| Subtree anchoring | Assignments a delegate creates must be anchored (`sys_user_position.business_unit_id`) inside their subtree, and the target user must sit inside it |
60
60
| Allowlist, no self-escalation | Every set reached by the write — bound to the assigned position, or granted directly — must be on `assignablePermissionSets`. Granting an un-allowlisted set is refused **for anyone, including the delegate themselves**|
61
-
| Action flags |`manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree |
61
+
| Action flags |`manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree. Under a `group` or `isolated` tenancy posture `authorEnvironmentSets` admits nothing at the data door: the platform operator authors the security catalog there (ADR-0131 D3). It still applies under `single`|
62
62
| Strict containment | Granting or authoring a set that itself carries an `adminScope` requires a held scope that **strictly contains** it — handing your own exact scope to a peer is refused (no lateral propagation) |
63
63
| Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked |
64
64
| Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically |
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+12-11Lines changed: 12 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
10
10
service self-write, a migration.
11
11
12
12
This page is **the authority** for what that flag actually does. It exists
13
-
because the flag is not one concept: it is a single boolean read at **125
13
+
because the flag is not one concept: it is a single boolean read at **130
14
14
distinct sites across 20 packages**, and knowing three of those behaviours gives
15
15
no hint that the other hundred-and-four exist. Every documented app-side bug
16
16
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -143,7 +143,7 @@ that silently does not happen.
143
143
144
144
### 3. Sharing (`plugin-sharing`)
145
145
146
-
The largest single consumer — **17 of the 125 sites**.
146
+
The largest single consumer — **17 of the 130 sites**.
147
147
148
148
| # | Behaviour when `isSystem`| What you get / what you lose | Anchor |
149
149
|:--|:---|:---|:---|
@@ -182,6 +182,7 @@ The largest single consumer — **17 of the 125 sites**.
182
182
| 53 | 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`|
183
183
| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability |`packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability`|
184
184
| 55 | 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`|
185
+
| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | 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, and at the data door on `sys_position`, `sys_permission_set` and `sys_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`|
185
186
| 56 | 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` |
186
187
| 57 | 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`|
187
188
| 58 | 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`|
@@ -286,7 +287,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
286
287
independent decisions, and a seed loader plausibly wants the first two but not
287
288
the third. The concept is nevertheless **staying as one boolean**:
288
289
289
-
-**Shipped semantics.**`isSystem` is a published contract with 125 read sites
290
+
-**Shipped semantics.**`isSystem` is a published contract with 130 read sites
290
291
in 20 packages. Splitting it is a breaking contract change across all of them.
291
292
(The ruling was taken when the census read 80 sites in 18 packages; the count
292
293
has grown, which strengthens rather than weakens the argument.)
@@ -358,19 +359,19 @@ still holds equal to the census on every pull request:
358
359
| — in tests | 1013 | — |
359
360
| — in non-test sources | 798 | — |
360
361
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
361
-
| — parsed as a declaration |28| ✅ |
362
+
| — parsed as a declaration |29| ✅ |
362
363
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
363
-
| — parsed as a property **read**|130| ✅ |
364
+
| — parsed as a property **read**|135| ✅ |
364
365
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
365
366
| — the remainder: text inside comments and string literals | 358 | — |
366
367
| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ |
367
-
| Of those reads: reads of `ExecutionContext.isSystem`|**125**| ✅ |
368
-
| — behaviour-bearing (rows 1–61 above) |122| ✅ |
368
+
| Of those reads: reads of `ExecutionContext.isSystem`|**130**| ✅ |
369
+
| — behaviour-bearing (rows 1–61 above) |127| ✅ |
369
370
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
370
371
| Packages containing at least one elevation read |**20**| ✅ |
371
-
| Files containing at least one elevation read |58| ✅ |
372
-
| — the distinct symbols those reads live in — what this page anchors |107| ✅ |
373
-
| — of those files, the ones holding more than one read in one symbol |8| ✅ |
372
+
| Files containing at least one elevation read |59| ✅ |
373
+
| — the distinct symbols those reads live in — what this page anchors |110| ✅ |
374
+
| — of those files, the ones holding more than one read in one symbol |9| ✅ |
374
375
375
376
The six rows marked — are a **dated decomposition, not a live claim**: they were
376
377
measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count
@@ -433,7 +434,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
433
434
Renaming a symbol is now a loud red instead of a silent misdirection.
434
435
435
436
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
436
-
cannot say WHICH read inside a function it means, and **8** of the **58**
437
+
cannot say WHICH read inside a function it means, and **9** of the **59**
437
438
anchored files hold more than one read inside a single symbol. So the population
438
439
check runs per file at symbol granularity: every file the census finds a read in
439
440
must be anchored, and the set of symbols this page cites into that file must
|**authorEnvironmentSets**|`boolean`| optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) |
36
36
|**assignablePermissionSets**|`string[]`| optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out |
37
37
38
38
@@ -183,7 +183,7 @@ const result = AdminScopeSchema.parse(data);
183
183
|**includeSubtree**|`boolean`| optional (default: `true`) | Cover descendant business units too (default true) |
184
184
|**manageAssignments**|`boolean`| optional (default: `false`) | Manage user↔position assignments within the subtree |
185
185
|**manageBindings**|`boolean`| optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
|**authorEnvironmentSets**|`boolean`| optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) |
187
187
|**assignablePermissionSets**|`string[]`| optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out |
0 commit comments