Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1f3f87f
wip(security): walled catalog refusal at the /meta doors and the data…
claude Oct 11, 2026
d039e4d
test(security): pin the walled catalog refusal on isolated and group,…
claude Oct 11, 2026
8d4d5ad
chore(security): changeset and ADR-0131 anchors for the walled catalo…
claude Oct 11, 2026
70f4feb
test(runtime): pin the dispatcher PUT's walled catalog refusal (ADR-0…
claude Oct 11, 2026
376af55
Merge remote-tracking branch 'origin/main' into claude/issue-15204-wa…
claude Oct 11, 2026
de89dd4
chore(spec,security): register the walled catalog refusal as an ADR-0…
claude Oct 11, 2026
d2a6934
Merge remote-tracking branch 'origin/main' into claude/issue-15204-wa…
claude Oct 11, 2026
26c70ab
fix(runtime): the permission-set clone door asks the walled catalog r…
claude Oct 11, 2026
763c52b
docs(permissions): census counts after the clone door's walled check;…
claude Oct 11, 2026
5354b16
chore(spec): the walled catalog refusal's changeset and ADR-0087 entr…
claude Oct 11, 2026
2243159
chore(spec): the walled catalog refusal's changeset and ADR-0087 surf…
claude Oct 11, 2026
a337a2f
docs(permissions,spec): the walled caveat on authorEnvironmentSets, t…
claude Oct 11, 2026
637f126
docs(references): regenerate the permission reference for the authorE…
claude Oct 11, 2026
a33c037
Merge origin/main (7de0265fc7) into claude/issue-15204-wall-catalog-r…
claude Oct 12, 2026
b1f2335
Merge origin/main (018b077fbf) into claude/issue-15204-wall-catalog-r…
claude Oct 12, 2026
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
28 changes: 28 additions & 0 deletions .changeset/15204-walled-catalog-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/metadata-core': minor
'@objectstack/rest': minor
'@objectstack/runtime': minor
'@objectstack/plugin-security': minor
'@objectstack/spec': minor
---

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

Clause-②: yes (narrowing)

<!-- adr-0087: registered walled-catalog-tenant-writes-retired -->

**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.").

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

**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 /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.
- 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.
- `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.
- 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.

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

**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`.
4 changes: 3 additions & 1 deletion content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,9 @@ one of two doors, each writing only what it owns:
CRUD) is transparently redirected into an env-scope metadata save
(`saveMetaItem`), and the data record is a **pure projection** the platform
re-derives on every metadata mutation (awaited — no staleness window) and at
boot. Deleting an artifact-backed set through this door **resets** it to its
boot. Under a `group` or `isolated` tenancy posture that Setup write is the
platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the
redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its
declared body rather than removing it; renaming through the data door is
rejected (the name is the metadata identity — clone instead). Subject
assignments remain plain config rows, unchanged.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Writes to the governed tables (`sys_user_position`,
| Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ |
| 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 |
| 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** |
| 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 |
| 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` |
| 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) |
| Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked |
| Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically |
Expand Down
4 changes: 3 additions & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,9 @@ it was copied from, so changes to the source never reach the copy.
- **The copy is held by nobody** until you add it to a position or assign it.
- **Who may call it** is who may save a permission set through the metadata
API: a holder of `manage_metadata`. Anyone else is refused `403`
`PERMISSION_DENIED`.
`PERMISSION_DENIED`. Under a `group` or `isolated` tenancy posture the clone
is the platform operator's alone (ADR-0131 D3): a `manage_metadata` holder
inside one organization is refused `403` `PERMISSION_DENIED` too.

### Declared ≠ enforced — diagnosing a frozen package set

Expand Down
23 changes: 12 additions & 11 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **125
because the flag is not one concept: it is a single boolean read at **130
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
Expand Down Expand Up @@ -143,7 +143,7 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **17 of the 125 sites**.
The largest single consumer — **17 of the 130 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
Expand Down Expand Up @@ -182,6 +182,7 @@ The largest single consumer — **17 of the 125 sites**.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down Expand Up @@ -286,7 +287,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 125 read sites
- **Shipped semantics.** `isSystem` is a published contract with 130 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand Down Expand Up @@ -358,19 +359,19 @@ still holds equal to the census on every pull request:
| — in tests | 1013 | — |
| — in non-test sources | 798 | — |
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 28 | ✅ |
| — parsed as a declaration | 29 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 130 | ✅ |
| — parsed as a property **read** | 135 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **125** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 122 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **130** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 127 | ✅ |
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 58 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 107 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
| Files containing at least one elevation read | 59 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 110 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 9 | ✅ |

The six rows marked — are a **dated decomposition, not a live claim**: they were
measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count
Expand Down Expand Up @@ -433,7 +434,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
Renaming a symbol is now a loud red instead of a silent misdirection.

⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
cannot say WHICH read inside a function it means, and **8** of the **58**
cannot say WHICH read inside a function it means, and **9** of the **59**
anchored files hold more than one read inside a single symbol. So the population
check runs per file at symbol granularity: every file the census finds a read in
must be anchored, and the set of symbols this page cites into that file must
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const result = AdminScopeSchema.parse(data);
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets |
| **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) |
| **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out |


Expand Down Expand Up @@ -183,7 +183,7 @@ const result = AdminScopeSchema.parse(data);
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets |
| **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) |
| **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out |

### Nested Shape: `PermissionSet.protection`
Expand Down
Loading
Loading