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
14 changes: 14 additions & 0 deletions .changeset/15204-permission-set-clone-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/runtime": minor
---

feat(runtime): `POST /api/v1/security/permission-sets/:name/clone` — clone a permission set by name from the security catalog (ADR-0126 §7.1)

Clause-②: yes (widening)

- **What is new.** A route that copies a permission set's whole definition under a new machine name. The body is `{ name, label }`: both are required, no other key is accepted, and `name` must be snake_case. The answer is `{ permissionSet, notice }`: the copy that was saved, and a sentence naming what was not copied.
- **Where it reads and writes.** The source is read through the security catalog (the engine registry first, then the metadata service), the same read the authorization resolver uses. It never reads a `sys_permission_set` row, so it works on a database with none. The copy is saved through the metadata protocol's `saveMetaItem` as a `permission` item authored in this environment, with no organization and no package. The save is create-only, so a row written under the name meanwhile is refused, not overwritten.
- **What is copied, and what is not.** Every facet of the definition is copied. One server-declared list (`PERMISSION_SET_CLONE_DROPPED_KEYS`) is removed: the protection envelope and read decorations (derived from the spec), `packageId`, `managedBy`, `protection`, and the two privilege decisions `adminScope` and `isDefault`. The copy records nothing about its source.
- **Who may call it.** A holder of `manage_metadata`, through the same verdict the metadata save door asks (`metaWriteCapabilityVerdict`); `isSystem` bypasses. Anyone else gets `403 PERMISSION_DENIED`, and an anonymous caller `401`.
- **Refusals.** `400 VALIDATION_FAILED` for a malformed body or a name failing the name rule; `404` for an unknown source; `409 RESOURCE_CONFLICT` for a name the catalog already holds, including the source's own; `503 SERVICE_UNAVAILABLE` when no catalog is bound or it cannot be read; `501` when no metadata protocol is composed.
- **Nothing to migrate.** No existing route changes. The Setup action `clone_permission_set` keeps working as before.
2 changes: 1 addition & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
| :--- | :--- |
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. |
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. The clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) copies the whole definition from the security catalog without reading a `sys_permission_set` record; the refusal does not name it yet. |

A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable.

Expand Down
35 changes: 33 additions & 2 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,9 @@ has three author-visible consequences:
an installed package declares (a `*.permission.ts` file, a stack's
`permissionSets`) or a platform default such as `member_default`. Editing it
through Setup or the metadata API answers `403 NOT_OVERRIDABLE` and stores
nothing. Clone it to a new name instead (the **Clone** action on the set, or
`POST /api/v1/data/sys_permission_set` with a new name) and edit the clone:
nothing. Clone it to a new name instead (the **Clone** action on the set,
`POST /api/v1/data/sys_permission_set` with a new name, or the clone door
below) and edit the clone:
the clone is your organization's own set, and package upgrades keep reaching
the original. Every other set (one created in this environment, a clone, or
one saved into a writable runtime package) is edited in place: the edit
Expand All @@ -388,6 +389,36 @@ in its place: restriction is done by not granting. Narrowing the packaged set
itself is the package author's change: they edit the declaration in the
package and re-publish it (ADR-0086 two-doors).

### Cloning a permission set (ADR-0126 §7.1)

```text
POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "..." }
```

The clone door copies a set's whole definition under a new machine name. It
reads the source from the security catalog, where every packaged and
environment-authored set lives, and never from a `sys_permission_set` record,
so it works with no record at all. It saves the copy through the metadata layer
as a set authored in this environment: no package, and no link back to the set
it was copied from, so changes to the source never reach the copy.

- **The body is `{ name, label }`, both required, and nothing else.** `name` is
a new snake_case machine name: a name failing that rule answers `400`, and a
name any permission set already uses (the source's own included) answers `409`
`RESOURCE_CONFLICT`. An unknown source answers `404`.
- **Every permission facet is copied** — `objects`, `fields`,
`systemPermissions`, `tabPermissions`, `rowLevelSecurity`, `description`.
- **The server drops a declared set of keys**: the package provenance
(`packageId`, `managedBy`, `protection` and the internal protection envelope),
and two privilege decisions — `adminScope` (delegated administration) and
`isDefault` (the `everyone` suggestion). Grant either deliberately on the copy
if it needs one. The response, `{ permissionSet, notice }`, says what was not
copied.
- **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`.

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

A package-declared set's *enforced* grants (what `sys_permission_set` stores
Expand Down
20 changes: 10 additions & 10 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 **123
because the flag is not one concept: it is a single boolean read at **124
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 @@ -142,7 +142,7 @@ that silently does not happen.

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

The largest single consumer — **17 of the 123 sites**.
The largest single consumer — **17 of the 124 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
Expand Down Expand Up @@ -174,7 +174,7 @@ The largest single consumer — **17 of the 123 sites**.
|:--|:---|:---|:---|:---|
| 47 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `packages/runtime/src/action-execution.ts#callData` |
| 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` |
| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict` |
| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` |
| 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` |
| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
| 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` |
Expand Down Expand Up @@ -285,7 +285,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 123 read sites
- **Shipped semantics.** `isSystem` is a published contract with 124 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 @@ -359,16 +359,16 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 28 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 129 | ✅ |
| — parsed as a property **read** | 130 | ✅ |
| — 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 | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 120 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **124** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 121 | ✅ |
| — 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 | 57 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 105 | ✅ |
| Files containing at least one elevation read | 58 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 106 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |

The six rows marked — are a **dated decomposition, not a live claim**: they were
Expand Down Expand Up @@ -432,7 +432,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 **57**
cannot say WHICH read inside a function it means, and **8** of the **58**
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
2 changes: 1 addition & 1 deletion packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// dispatcher domain files.
//
// The population comes from `packages/rest/src/rest-route-ledger.ts` (81 rows
// / 18 families) and `packages/runtime/src/route-ledger.ts` (87 rows / 23
// / 18 families) and `packages/runtime/src/route-ledger.ts` (88 rows / 23
// domains) because those two are enumerated from a RUNNING server and guarded
// in both directions by their own conformance tests — so a new family or
// domain cannot be silently absent from them, and therefore cannot be silently
Expand Down
16 changes: 12 additions & 4 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
// `RestServer.getRoutes()` on a booted server and guarded per route by
// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars;
// this table reaches 1.
// `packages/runtime/src/route-ledger.ts`: 87 rows over 23 domains. Its
// `packages/runtime/src/route-ledger.ts`: 88 rows over 23 domains. Its
// machine contract is DOMAIN-level, by live registry introspection
// (`domainRegistry.list()`), the per-route rows being documentation. It
// covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all
Expand Down Expand Up @@ -438,11 +438,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
// `blindSpot` stays 0, `keys` stays 23 and the domain-file counts in the
// note below are unchanged. Controls moved with the row ("route: '" and
// "domain: '" 86 -> 87, RouteLedgerEntry unchanged at 2).
population: 87,
reachable: 87,
// [ADR-0126 §7.1] 87 -> 88: the `POST /security/permission-sets/:name/clone`
// row arrived with its mount (the permission-set clone door). It carries
// `domain: '/security'`, an EXISTING key, so `reachable` moves with
// `population`, `blindSpot` stays 0 and `keys` stays 23. Its module,
// `domains/permission-set-clone.ts`, declares no DomainRoute prefix — the
// `/security` domain dispatches to it — so the domain-file counts in the
// note below are unchanged. Controls moved with the row ("route: '" and
// "domain: '" 87 -> 88, RouteLedgerEntry unchanged at 2).
population: 88,
reachable: 88,
blindSpot: 0,
populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)',
controls: { "route: '": 87, "domain: '": 87, RouteLedgerEntry: 2 },
controls: { "route: '": 88, "domain: '": 88, RouteLedgerEntry: 2 },
// [#22432] Re-derived on the merged ref: the note's two counts had stood at
// 11 files / 5 classified / 16 baselined since before the `/analytics`
// domain was classified (#21061), which named one more file and moved one
Expand Down
Loading
Loading