From 1ff262c9fccce2d328a26c7d72e2865df2b3c987 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:19:03 +0000 Subject: [PATCH 1/4] feat(plugin-security,rest,spec)!: retire the permission-set overlay discard (ADR-0131 cutover stage 7-pre) U1 measured on main: no caller-reachable door creates an environment overlay of a package-declared permission set any more (the metadata door seals it on every topology, the data door's packaged lock refuses it, the data API cannot write sys_metadata, and a package declaring a held name is refused at install), and a database still holding one is refused at cold boot (ADR-0048). The discard action therefore had nothing it could reach on a running server, and the row it re-projected is no longer read for grants. Removes the action module and its tests, the REST route and its ledger row, the Setup action and its translations, the optional ISecurityService member and its result type, and plugin-security's two error-code provenance rows (both codes stay registered under @objectstack/rest). Registers the retirement as ADR-0087 semantic entry security-permission-set-overlay-discard-retired, and points every remaining reference (docs, the boot reading, the drift diagnostic) at the offline `os migrate security-catalog-overlays` step. Claude-Session: https://claude.ai/code/session_019TtY6pnoZSemcQRzbRUm5e Co-authored-by: Claude --- .../15204-s7pre-overlay-discard-retired.md | 34 ++ content/docs/concepts/metadata-lifecycle.mdx | 2 +- .../docs/deployment/environment-variables.mdx | 2 +- content/docs/permissions/permission-sets.mdx | 14 +- content/docs/permissions/system-context.mdx | 19 +- .../docs/permissions/tenant-audit-census.mdx | 34 +- ...08-tenant-audit-write-call-sites.counts.md | 15 +- packages/metadata-protocol/src/protocol.ts | 5 +- packages/plugins/plugin-security/src/index.ts | 12 +- .../src/objects/rbac-objects.test.ts | 5 +- .../src/objects/sys-permission-set.object.ts | 26 +- .../src/packaged-permission-set-lock.test.ts | 2 +- .../src/packaged-permission-set-lock.ts | 11 +- ...ckaged-permission-set-overlay-detection.ts | 12 +- .../src/permission-set-drift.test.ts | 50 +- .../src/permission-set-drift.ts | 12 +- .../permission-set-overlay-discard.test.ts | 495 ------------------ .../src/permission-set-overlay-discard.ts | 353 ------------- .../src/permission-set-projection.ts | 6 +- ...tered-security-service-members.pin.test.ts | 16 +- .../plugin-security/src/security-plugin.ts | 28 +- .../src/translations/en.objects.generated.ts | 7 +- .../translations/es-ES.objects.generated.ts | 7 +- .../es-ES.source-hashes.generated.ts | 5 +- .../translations/ja-JP.objects.generated.ts | 7 +- .../ja-JP.source-hashes.generated.ts | 5 +- .../translations/zh-CN.objects.generated.ts | 7 +- .../zh-CN.source-hashes.generated.ts | 5 +- .../plugin-security/src/write-refusals.ts | 9 +- ...iscard-overlay-eligibility.dogfood.test.ts | 278 ---------- .../rest/src/execctx-consumer-census.test.ts | 29 +- packages/rest/src/rest-route-ledger.ts | 12 +- packages/rest/src/rest-server.ts | 39 -- ...-response-internal-fields.tripwire.test.ts | 7 - ...route-hook-refusal-sentence.ledger.test.ts | 5 +- packages/spec/api-surface/contracts.json | 1 - packages/spec/export-origins/contracts.json | 1 - .../spec/src/api/error-code-ledger.test.ts | 2 - .../spec/src/api/error-code-ledger.zod.ts | 19 +- .../src/contracts/security-service.test.ts | 35 -- .../spec/src/contracts/security-service.ts | 71 --- ...-permission-set-overlay-discard-retired.ts | 44 ++ scripts/engine-double-contract.pinned.json | 10 - scripts/objectql-double-limit.baseline.json | 3 - 44 files changed, 193 insertions(+), 1568 deletions(-) create mode 100644 .changeset/15204-s7pre-overlay-discard-retired.md delete mode 100644 packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-overlay-discard.ts delete mode 100644 packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.security-permission-set-overlay-discard-retired.ts diff --git a/.changeset/15204-s7pre-overlay-discard-retired.md b/.changeset/15204-s7pre-overlay-discard-retired.md new file mode 100644 index 00000000000..16bacfd64c0 --- /dev/null +++ b/.changeset/15204-s7pre-overlay-discard-retired.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +'@objectstack/rest': minor +--- + +feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + +Clause-②: no (narrowing) + + + +**BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + +The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + +- No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. +- An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. +- The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + +### What was removed + +| removed | what to use instead | +| --- | --- | +| `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | +| the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | +| `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | +| `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + +**The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + +`ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + +The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index cb4d1bca4ab..d2d11575629 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -142,7 +142,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged. -An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had. +An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: the deployment does not boot while it holds one (ADR-0048), and `os migrate security-catalog-overlays` lists and, with `--apply`, deletes it. --- diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 3d694a5eb19..29f244bfdbe 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | | `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. | -| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: it is still served, with its detection reading and Discard Overlay. Removing that permission-set row restores the package's definition. A stored row over a managed item of the other types that merge overlays at read (`position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed, but removing it changes nothing that is served: no read serves it, so the package's definition is already served. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | +| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: the deployment does not boot while it holds one (ADR-0048); `os migrate security-catalog-overlays` lists such rows and, with `--apply`, deletes them, which restores the package's definition. A stored row over a managed item of the other types that merge overlays at read (`position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed, but removing it changes nothing that is served: no read serves it, so the package's definition is already served. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | --- diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index eef1295c08f..31645d7e435 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -382,15 +382,13 @@ either can be live on one row, and they need different remedies: (from a Studio save made while overlaying was still permitted, or through the `OS_METADATA_WRITABLE` escape hatch) is re-projected onto the record on every boot, unconditionally — the overlay wins forever, regardless of what - the package ships next. **Remedy:** the record's **Discard Overlay** Setup - action (`POST /api/v1/security/permission-sets/:id/discard-overlay`, - tenant-admin only) removes the stale overlay and resyncs the record to the - current artifact immediately. It refuses on any set that no installed code - package ships (a set created in this environment, a clone, or a set saved - into a writable runtime package), so it can never destroy a genuinely - environment-authored set. **Discard it before you upgrade:** from the + the package ships next. No door creates such an overlay any more: the + metadata door refuses a save over a packaged set, and so does the data door + (clone the set instead). **Remedy:** delete the overlay row. From the release that adds ADR-0048's cold-boot check, a deployment that still holds - such an overlay does not boot, so the action can no longer reach it. + one does not boot: `os migrate security-catalog-overlays` lists every such + row with no server running, and `--apply` deletes them, after which the + package's definition is served again. - **Provenance skip.** The record's `managed_by` column predates package provenance tracking (a legacy insert without `managed_by: 'package'` — typically a database first initialized on an older release line), so boot diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3e9919c52a3..d470b485a4f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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 **122 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, @@ -112,7 +112,6 @@ that silently does not happen. | 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the seeders and the package door write rows for definitions that already have their home, untouched. Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | -| 14 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin` | | 15 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure` | | 16 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `packages/plugins/plugin-audit/src/read-audit.ts#installReadAuditWriter` | | 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read. Lose: field-visibility redaction over approval payloads, and the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware`, `packages/plugins/plugin-approvals/src/payload-predicate-guard.ts#bindSnapshotPredicateGuard` | @@ -143,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 122 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -286,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 122 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.) @@ -360,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** | 128 | ✅ | | — 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` | **122** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 119 | ✅ | | — 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 | 105 | ✅ | +| Files containing at least one elevation read | 57 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 104 | ✅ | | — 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 @@ -433,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 **58** +cannot say WHICH read inside a function it means, and **8** of the **57** 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 diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 893f8fda28f..5361072f54e 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 51 of them — just over a fifth +typed `any` have no type to read, and there are 50 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 239 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 238 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **239** | +| 175 write call sites | quoted in the merged changeset | **238** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 239** decidable, **79** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 105 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **159 of 238** decidable, **79** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 104 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 51 sites reached +one, and both are counted in the generated tables below: the 50 sites reached through an erased (`any`) receiver, and the 53 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -126 of 239 (53%) as decidably elevated, with 105 more whose elevation is a +126 of 238 (53%) as decidably elevated, with 104 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 239`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 238`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **239** | -| …whose object name is statically decidable | 160 | +| write call sites on the application surface | **238** | +| …whose object name is statically decidable | 159 | | …whose object name is chosen at run time | 79 | -| …against an object with tenancy ENABLED | 159 | +| …against an object with tenancy ENABLED | 158 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 178 | +| threading a tenant context | 177 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | | threading a decidably ELEVATED (`isSystem`) context | 126 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 105 | +| threading a context whose elevation is a run-time fact | 104 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 188 | -| receiver erased, placed by the object NAME | 33 | +| receiver erased, placed by the object NAME | 32 | | receiver erased, placed by an `object: string` PARAMETER | 13 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 107 | +| object name spelled inline | 106 | | object name spelled through a `const` | 53 | | object name is an `object: string` parameter | 17 | | object name is some other run-time expression | 62 | @@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-10 at `b88b35678`. +Measured on 2026-10-11 at `a18c51496`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 631 | +| tracked non-test sources scanned | 637 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index e274581676d..1c1bc005d25 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 239 | -| Object name statically decidable | 160 | +| Write call sites | 238 | +| Object name statically decidable | 159 | | Object name chosen at run time | 79 | -| Against a tenancy-enabled object | 159 | +| Against a tenancy-enabled object | 158 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 178 | +| Threading a tenant context | 177 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | | Threading a decidably elevated context | 126 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 105 | +| Threading a context of undecidable elevation | 104 | ## Subtractions the census could NOT defend — enforced @@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-10 at `b88b35678`. +Measured on 2026-10-11 at `a18c51496`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 631 | +| tracked non-test sources scanned | 637 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | @@ -171,7 +171,6 @@ Measured on 2026-10-10 at `b88b35678`. | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts` | `delete` | `sys_metadata` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a0e4a6bbaa4..2636dca1e3e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16595,8 +16595,9 @@ export class ObjectStackProtocolImplementation implements * ⛔ Except `permission`. A permission set a code package ships is guarded * by its own lock (ADR-0086 / ADR-0094, `packaged-permission-set-lock`), * and the forks stored before that lock existed have a maintainer ruling - * of their own: a detection reading and an operator's Discard Overlay - * action, "no automatic reap/merge … not a silent migration". Not serving + * of their own: a detection reading, "no automatic reap/merge … not a + * silent migration" (a cold boot now refuses such a fork, ADR-0048, and + * the offline `os migrate security-catalog-overlays` removes it). Not serving * such a fork would change the grants the deployment enforces without an * operator's act, so it keeps that ruling's disposition. */ diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index c3220bcd134..a4b82c9dc27 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -164,8 +164,7 @@ export type { SuggestionReconcileScope, } from './suggested-audience-bindings.js'; // [field report — rc→GA declared≠enforced surfacing] "declared ≠ enforced" -// per-set diagnostics (overlay_shadow / provenance_skip) + the sanctioned -// operator discard action. +// per-set diagnostics (overlay_shadow / provenance_skip). export { computePermissionSetDriftDiagnostics, persistPermissionSetDriftDiagnostics, @@ -176,15 +175,6 @@ export type { PermissionSetDriftDiagnostic, DriftDiagnosticsOptions, } from './permission-set-drift.js'; -export { - discardPermissionSetOverlay, - PermissionSetNotFoundError, - PermissionSetOverlayStateError, -} from './permission-set-overlay-discard.js'; -export type { - PermissionSetOverlayDiscardDeps, - PermissionSetOverlayDiscardResult, -} from './permission-set-overlay-discard.js'; // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」] // Lock the base, clone to customize: the write-door provenance rule and its // refusals, plus the DETECTION READING for overlays that already exist. diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index 52324ab6e41..eb2f6c7155a 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -201,13 +201,10 @@ describe('RBAC object canonical names + row actions', () => { expect(names).toEqual(['activate_position', 'clone_position', 'deactivate_position', 'set_default_position']); }); - it('SysPermissionSet exposes activate/deactivate/clone/discard-overlay row actions', () => { + it('SysPermissionSet exposes activate/deactivate/clone row actions', () => { const names = (SysPermissionSet.actions ?? []).map((a) => a.name).sort(); - // [field report — rc→GA declared≠enforced surfacing] `discard_permission_set_overlay` - // is the sanctioned counterpart to the field remediation's raw SQL. expect(names).toEqual([ 'activate_permission_set', 'clone_permission_set', 'deactivate_permission_set', - 'discard_permission_set_overlay', ]); }); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 569eefef3a3..fe388882bfa 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -68,29 +68,6 @@ export const SysPermissionSet = ObjectSchema.create({ successMessage: 'Permission set deactivated', refreshAfter: true, }, - { - // [field report — rc→GA declared≠enforced surfacing] The sanctioned, - // audited counterpart to the field remediation's raw - // `DELETE FROM sys_metadata WHERE type='permission' AND name='…'` — see - // `permission-set-overlay-discard.ts`. Refuses (403) on any set that is - // not currently package-declared, so it can never destroy a genuinely - // environment-authored set's work. - name: 'discard_permission_set_overlay', - label: 'Discard Overlay', - icon: 'refresh-ccw', - variant: 'danger', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'POST', - target: '/api/v1/security/permission-sets/{id}/discard-overlay', - confirmText: - 'Discard the environment overlay shadowing this package-declared set and resync it to the shipped ' + - 'artifact? This only removes the customization overlay — it never touches an environment-authored set.', - successMessage: 'Overlay discarded — the set is resynced to the shipped artifact', - refreshAfter: true, - visible: "has(record.drift_status) && record.drift_status == 'overlay_shadow'", - }, { // [commit 5cb62d88b] ⭐ THIS PARAMS LIST *IS* THE PAYLOAD. Every definition facet a // clone should carry has to be named below: the action POSTs its param @@ -378,8 +355,7 @@ export const SysPermissionSet = ObjectSchema.create({ readonly: true, description: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-" + - "authored environment overlay is shadowing the packaged declaration — resync it with the Discard " + - "Overlay action. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + + "authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + "treats it as environment-authored and never reconciles it with the package (no automated fix — file " + "an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the " + "shipped artifact, or this set is not currently package-declared.", diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts index bae48395acf..df359baec2f 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -58,7 +58,7 @@ * budget reaches. * * So the provenance question is decided from the engine's SchemaRegistry — the - * same source `readDeclared` / `permission-set-overlay-discard.ts` already use, + * same source `readDeclared` already uses, * an in-memory array with no page, no cap and no `$in`. Two controls prove the * immunity structurally rather than asserting it: * diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 483e5e6062d..34a2f9eaccc 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -46,9 +46,8 @@ * page in it at all. * * ⭐ The answer comes from the engine's SchemaRegistry instead — the same - * source `bootstrapDeclaredPermissions`' {@link readDeclared} and - * `permission-set-overlay-discard.ts`'s eligibility test already use, so this - * plugin keeps ONE spelling of "package-declared" rather than two (Prime + * source `bootstrapDeclaredPermissions`' {@link readDeclared} already uses, + * so this plugin keeps ONE spelling of "package-declared" rather than two (Prime * Directive #8). That read is an in-memory array: no page, no cap, no `$in`, * no driver. Truncation is not a failure mode it HAS, which is a structural * property rather than a promise — `packaged-permission-set-lock.test.ts` @@ -74,8 +73,7 @@ * keeps on purpose, and would be the over-broad refusal the ruling did not * ask for. * - * So provenance is decided from the ARTIFACT, exactly as - * `permission-set-overlay-discard.ts` decided it for the discard action. + * So provenance is decided from the ARTIFACT. * * ## Fail-closed on ambiguity * @@ -309,8 +307,7 @@ const PROVENANCE_UNKNOWN_USER_MESSAGE = * member: it is an ADR-0112 registered extension code, and because this * package stamps it, `ERROR_CODE_LEDGER['@objectstack/plugin-security']` * lists it beside metadata-protocol's row (provenance, not identity — a code - * shipped in `dist` is registered under every package that stamps it; the - * same holds for the two overlay-discard codes). What changes is the + * shipped in `dist` is registered under every package that stamps it). What changes is the * MESSAGE: the producer's says the type has not opted into overlay writes, * which tells an admin nothing they can act on. The ruling's whole point is * that the refusal teaches the sanctioned path. diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts index 564e70088f6..5197d6e66b2 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -14,9 +14,9 @@ * ⛔ So this module READS. It has no write verb, takes no `update`/`delete` * path, and its test double deliberately exposes none either — a reap added * here would fail with a `TypeError` rather than pass quietly. The disposition - * of an existing fork is the maintainer's call, and the sanctioned per-set - * remedy already exists as an explicit, audited operator action - * (`permission-set-overlay-discard.ts`), invoked one set at a time by a human. + * of an existing fork is the maintainer's call. A deployment upgraded onto + * ADR-0048's cold-boot check refuses to boot while it holds such a fork, and + * the offline `os migrate security-catalog-overlays` lists and deletes it. * * ## Why this is not `permission-set-drift.ts` * @@ -161,9 +161,9 @@ export async function reportPackagedPermissionSetOverlays( `[security] ${reading.count} package-declared permission set(s) are being shadowed by an environment ` + 'overlay — each one was forked from its package before the save door was locked, so its grants are ' + 'frozen at the overlay and every future package upgrade of that set will be ignored, silently. ' + - 'DETECTION ONLY: nothing was reaped, removed or changed by this reading. To resync one set to its ' + - 'shipped artifact, use the audited "Discard Overlay" action on it; to keep the customization, clone the ' + - 'set and reassign to the clone.', + 'DETECTION ONLY: nothing was reaped, removed or changed by this reading. To resync a set to its ' + + 'shipped artifact, delete its overlay row (`os migrate security-catalog-overlays` lists every such row ' + + 'and deletes them with --apply); to keep the customization, clone the set and reassign to the clone.', { count: reading.count, names: reading.names, findings: reading.findings }, ); return reading; diff --git a/packages/plugins/plugin-security/src/permission-set-drift.test.ts b/packages/plugins/plugin-security/src/permission-set-drift.test.ts index 2aba4326568..f1cf7524ee9 100644 --- a/packages/plugins/plugin-security/src/permission-set-drift.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-drift.test.ts @@ -23,10 +23,6 @@ import { runPermissionSetDriftDiagnostics, } from './permission-set-drift.js'; import { permissionSetRowFields } from './permission-set-projection.js'; -import { - discardPermissionSetOverlay, - type PermissionSetOverlayDiscardDeps, -} from './permission-set-overlay-discard.js'; /** * Minimal in-memory ql: sys_permission_set + sys_metadata + $in support. @@ -151,7 +147,7 @@ describe('computePermissionSetDriftDiagnostics — pin 1 & 3: both mechanisms de const [d] = await computePermissionSetDriftDiagnostics(ql); expect(d.status).toBe('overlay_shadow'); expect(d.detail).toContain('overlay'); - expect(d.detail).toContain('Discard'); + expect(d.detail).not.toContain('Discard'); }); it("provenance_skip: managed_by is NOT 'package' (legacy row), no overlay present", async () => { @@ -331,15 +327,13 @@ describe('runPermissionSetDriftDiagnostics — pin 6: a refused diagnostic write /* ------------------------------------------------------------------------- * - * [#21860] the declared population is the lock classifier's, and it is the - * population the Discard Overlay action accepts + * [#21860] the declared population is the lock classifier's * * A set saved into a writable runtime package carries its package id on its * registry body after the first metadata list read * (`{ _packageId: , _provenance: 'org' }`). This report read * that as "package-declared": a drifted runtime-package set with a stored - * definition was diagnosed `overlay_shadow`, and the detail names Discard - * Overlay as the remedy, which deleted the set's only stored definition. + * definition was diagnosed `overlay_shadow`. * ------------------------------------------------------------------------- */ const RUNTIME_PACKAGE = 'com.dogfood.runtime_pkg'; @@ -370,7 +364,7 @@ function seedDrifted(ql: ReturnType, body: any, packageId: string describe('[#21860] computePermissionSetDriftDiagnostics — the declared population is the lock classifier\'s', () => { for (const shape of envShapes) { - it(`a drifted ${shape.label} with a stored definition is never diagnosed (and never told to Discard Overlay)`, async () => { + it(`a drifted ${shape.label} with a stored definition is never diagnosed`, async () => { const ql = makeQl([declaredSet({ _provenance: 'package' }), shape.body]); ql.permRows.push(inSyncRow()); seedDrifted(ql, shape.body, shape.packageId); @@ -382,40 +376,4 @@ describe('[#21860] computePermissionSetDriftDiagnostics — the declared populat .toEqual([{ name: 'ehr_quality_inspector', status: 'in_sync', packageId: 'com.example.ehr' }]); }); } - - it('the report and the Discard Overlay action judge the same population: reported ⇔ eligible', async () => { - const shipped = declaredSet({ _provenance: 'package' }); - const ql = makeQl([shipped, ...envShapes.map((s) => s.body)]); - ql.permRows.push(inSyncRow()); // code-shipped, no overlay: eligible, and 409 for want of one - const ids: Record = { [shipped.name]: 'ps_1' }; - for (const shape of envShapes) ids[shape.body.name] = seedDrifted(ql, shape.body, shape.packageId); - - const reported = new Set((await computePermissionSetDriftDiagnostics(ql)).map((d) => d.name)); - - const deps: PermissionSetOverlayDiscardDeps = { - ql, - resolveSets: async () => [{ name: 'platform_admin', objects: { '*': { modifyAllRecords: true } }, fields: {}, systemPermissions: [] } as any], - getProtocol: () => undefined, - logger: { info: () => {}, warn: () => {}, error: () => {} }, - }; - const verdicts: Record = {}; - for (const [name, id] of Object.entries(ids)) { - const discard = await discardPermissionSetOverlay(deps, { userId: 'u_admin' }, id).then( - () => 'accepted', - (e: any) => ({ code: e?.code, status: e?.status ?? e?.statusCode }), - ); - verdicts[name] = { reported: reported.has(name), discard }; - } - - expect(verdicts).toEqual({ - // Eligible (past the refusal), and nothing to discard. - ehr_quality_inspector: { reported: true, discard: { code: 'INVALID_STATE', status: 409 } }, - rt_pkg_set: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } }, - org_owned_set: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } }, - contributor_clone: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } }, - runtime_only_set: { reported: false, discard: { code: 'PERMISSION_DENIED', status: 403 } }, - }); - // Nothing was deleted: every stored definition is still there. - expect(ql.metaRows.map((r) => r.name).sort()).toEqual(['contributor_clone', 'org_owned_set', 'rt_pkg_set', 'runtime_only_set']); - }); }); diff --git a/packages/plugins/plugin-security/src/permission-set-drift.ts b/packages/plugins/plugin-security/src/permission-set-drift.ts index 9835d0df788..5beec3e4326 100644 --- a/packages/plugins/plugin-security/src/permission-set-drift.ts +++ b/packages/plugins/plugin-security/src/permission-set-drift.ts @@ -43,10 +43,7 @@ * mismatch — it re-derives the fact each boot from the current `sys_metadata` * / `sys_permission_set` state, the same sources the two write-doors read. * It also never adopts/migrates a row (out of scope by 2026-08-20 maintainer - * ruling — "新项目还没上线,不需要清理旧数据,也没有老客户升级"; the - * `sanctioned discard` counterpart lives in `permission-set-overlay-discard.ts` - * and only ever touches the `sys_metadata` overlay row, never `managed_by` / - * `package_id`). + * ruling — "新项目还没上线,不需要清理旧数据,也没有老客户升级"). * * ⚠️ Scope note shared with `reconcilePermissionSetProjection`: only * ENV-WIDE overlays (`sys_metadata.organization_id IS NULL`) are considered @@ -128,11 +125,10 @@ export async function computePermissionSetDriftDiagnostics( // [#21860] "Package-declared" is the lock's verdict for the NAME — a code // (artifact) package ships it — read through the one classifier the write - // doors, the overlay detection reading and the Discard Overlay action ask. + // doors and the overlay detection reading ask. // A package id on the item alone is not that answer: a set saved into a // writable runtime package carries one after the first metadata list read, - // and this report used to diagnose it as package-declared and name Discard - // Overlay as the remedy, an action that deletes its only stored definition. + // and this report used to diagnose it as package-declared. // The per-item test that follows the verdict only picks which bodies of a // code-shipped name are compared, exactly as before. const codeShipped = new Map(); @@ -194,7 +190,7 @@ export async function computePermissionSetDriftDiagnostics( detail = `An environment customization overlay (sys_metadata, type:'permission', name:'${name}') is shadowing ` + `this package-declared set — enforced grants (${enforcedCount} object(s)) come from the overlay, not ` + - `the shipped artifact (${declaredCount} object(s)). Discard the overlay (Discard Overlay action) to resync.`; + `the shipped artifact (${declaredCount} object(s)).`; } else if (row.managed_by !== 'package') { status = 'provenance_skip'; detail = diff --git a/packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts b/packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts deleted file mode 100644 index b7f91f98923..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The sanctioned, audited discard action (field report ask #2 — the field - * remediation had to delete a `sys_metadata` row by raw SQL; this is that - * same operation as a supported code path). Pins: - * - * 4. the discard action actually restores sync — the healed GRANT SET is - * asserted, not just "the action returned ok"; - * 5. ⭐ the discard action refuses what it must — it targets package- - * declared sets only, so a genuinely environment-authored set (no - * current package declaration under that name) is refused, with no - * mutation. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { - discardPermissionSetOverlay, - PermissionSetNotFoundError, - PermissionSetOverlayStateError, - type PermissionSetOverlayDiscardDeps, -} from './permission-set-overlay-discard.js'; -import { PermissionDeniedError } from './errors.js'; -import { permissionSetRowFields } from './permission-set-projection.js'; - -/** - * Minimal in-memory ql: sys_permission_set + sys_metadata + delete support. - * - * `refuseUpdatesWith` makes every `update` throw AFTER the dispatch predicate - * has accepted the call shape — the real failure ORDER (the engine validates, - * the store refuses), and the only one `tryUpdate`'s catch ever sees. Extended - * in place rather than added as a second double: this file's `update`/`delete` - * doubles are pinned 1-per-file in `scripts/engine-double-contract.pinned.json`. - */ -function makeQl(declared: any[] = [], refusal: { refuseUpdatesWith?: Error } = {}) { - const permRows: any[] = []; - const metaRows: any[] = []; - const tableFor = (object: string) => - object === 'sys_permission_set' ? permRows : object === 'sys_metadata' ? metaRows : null; - const matches = (r: any, where: any) => - Object.entries(where ?? {}).every(([k, v]) => { - // REFUSE the combinators this double does not implement rather than - // reading `$and`/`$or` as a column name (check:where-matcher / - // #8494) -- discardPermissionSetOverlay's ql.find/update/delete calls - // (by id, or by type+name+state) never issue one; a matcher that - // silently treated a combinator as a field name would let a future - // combinator query pass this suite while answering the wrong rows. - if (k.startsWith('$')) { - throw new Error(`fake driver: unsupported combinator ${k}`); - } - return r[k] === v; - }); - return { - permRows, - metaRows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - const rows = tableFor(object); - return rows ? rows.filter((r) => matches(r, q?.where)) : []; - }, - // Routed through the real dispatch predicate (#4434 / check:engine-double-contract): - // a fake looser than ObjectQL.update would let discardPermissionSetOverlay's - // fallback (degraded-kernel) path drift to a call shape the real engine refuses. - async update(object: string, data: any, options?: any) { - const rows = tableFor(object); - const dispatch = assertEngineUpdateDispatch(data, options); - if (refusal.refuseUpdatesWith) throw refusal.refuseUpdatesWith; - const targets = dispatch.kind === 'by-id' - ? (rows ?? []).filter((r: any) => r.id === dispatch.id) - : (rows ?? []).filter((r: any) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - // Same reason, delete half: this is the exact mouth `discardPermissionSetOverlay` - // uses to remove the stale `sys_metadata` overlay row. - async delete(object: string, opts: any) { - const rows = tableFor(object); - const dispatch = assertEngineDeleteDispatch(opts); - if (!rows) return dispatch.kind === 'by-id' ? false : 0; - const targets = dispatch.kind === 'by-id' - ? rows.filter((r: any) => r.id === dispatch.id) - : rows.filter((r: any) => matches(r, opts?.where)); - const remaining = rows.filter((r: any) => !targets.includes(r)); - rows.length = 0; - rows.push(...remaining); - return dispatch.kind === 'by-id' ? targets.length > 0 : targets.length; - }, - }; -} - -/** Fake metadata protocol: `getMetaItemLayered` reads the overlay straight off `ql.metaRows`. */ -function makeProtocol(ql: ReturnType) { - return { - async getMetaItemLayered({ name }: { type: string; name: string }) { - const overlayRow = ql.metaRows.find( - (r: any) => r.type === 'permission' && r.name === name && r.state === 'active' && (r.organization_id ?? null) === null, - ); - const overlay = overlayRow ? JSON.parse(overlayRow.metadata) : null; - return { type: 'permission', name, code: null, overlay, effective: overlay }; - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'ehr_quality_inspector', - label: 'Quality Inspector', - objects: { obj_a: { allowRead: true }, obj_b: { allowRead: true }, obj_c: { allowRead: true } }, // 3-object artifact - _packageId: 'com.example.ehr', - ...over, -}); - -const overlayRow = (staleObjects: Record) => ({ - id: 'meta_1', type: 'permission', name: 'ehr_quality_inspector', state: 'active', organization_id: null, - metadata: JSON.stringify({ name: 'ehr_quality_inspector', objects: staleObjects }), -}); - -const tenantAdminCtx = { userId: 'u_admin', isSystem: false }; - -function deps(ql: ReturnType, protocol: any = makeProtocol(ql)): PermissionSetOverlayDiscardDeps { - return { - ql, - metadata: { registerInMemory() {}, get: async () => undefined, unregister() {} }, - // `isTenantAdmin` (delegated-admin-gate.ts) keys on a resolved set's `'*'` - // wildcard `modifyAllRecords: true` — NOT `systemPermissions` — so the - // fixture representing "caller is a tenant admin" has to carry that. - resolveSets: async () => [{ name: 'platform_admin', objects: { '*': { modifyAllRecords: true } }, fields: {}, systemPermissions: [] } as any], - getProtocol: () => protocol, - logger: { info: () => {}, warn: () => {}, error: () => {} }, - }; -} - -describe('discardPermissionSetOverlay — pin 4: actually restores sync', () => { - it('discards the stale overlay and heals the row to the CURRENT declared artifact (asserts the healed grant set)', async () => { - const artifact = declaredSet(); // ships 3 objects - const ql = makeQl([artifact]); - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'package', package_id: 'com.example.ehr', - ...permissionSetRowFields(artifact), - // enforced content overridden below to the STALE overlay's 2-object shape - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), - }); - ql.metaRows.push(overlayRow({ obj_a: {}, obj_b: {} })); // the stale overlay (2 objects, missing obj_c) - - const result = await discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_1'); - - expect(result.overlaysDiscarded).toBe(1); - expect(ql.metaRows).toHaveLength(0); // the overlay row is gone - // The healed row now enforces the ARTIFACT's full 3-object grant set — - // not merely "an update happened". - expect(result.healedObjectGrantCount).toBe(3); - const healedObjects = JSON.parse(result.permissionSet.object_permissions); - expect(Object.keys(healedObjects).sort()).toEqual(['obj_a', 'obj_b', 'obj_c']); - expect(result.permissionSet.customized).toBe(false); // no overlay left to badge - }); - - it('the CONFOUNDED field-reported shape (managed_by wrong AND overlay present) still discards and heals — eligibility is NOT gated on managed_by', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'admin', // wrong provenance — the confound - ...permissionSetRowFields(artifact), - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - }); - ql.metaRows.push(overlayRow({ obj_a: {} })); - - const result = await discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_1'); - expect(result.healedObjectGrantCount).toBe(3); - expect(ql.metaRows).toHaveLength(0); - }); -}); - -describe('discardPermissionSetOverlay — pin 5: refuses what it must', () => { - it('refuses a set with NO current package declaration (genuinely environment-authored) — no mutation', async () => { - const ql = makeQl([]); // nothing declared by any package - ql.permRows.push({ - id: 'ps_env', name: 'sales_readonly', managed_by: 'admin', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - }); - ql.metaRows.push({ - id: 'meta_env', type: 'permission', name: 'sales_readonly', state: 'active', organization_id: null, - metadata: JSON.stringify({ name: 'sales_readonly', objects: { crm_lead: { allowRead: true } } }), - }); - - await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_env')) - .rejects.toBeInstanceOf(PermissionDeniedError); - - // No mutation: the overlay is still there, the row is untouched. - expect(ql.metaRows).toHaveLength(1); - expect(JSON.parse(ql.permRows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('a NAME COLLISION with a genuinely env-authored set is refused even though a DIFFERENT package declares an unrelated set (name match against the declared registry only)', async () => { - // `readDeclared` returns items for OTHER names too — eligibility must - // match on the TARGET row's own name, not "some package declares - // something". - const ql = makeQl([declaredSet({ name: 'crm_admin' })]); - ql.permRows.push({ id: 'ps_env', name: 'sales_readonly', managed_by: 'admin', object_permissions: '{}' }); - ql.metaRows.push({ - id: 'meta_env', type: 'permission', name: 'sales_readonly', state: 'active', organization_id: null, - metadata: JSON.stringify({ name: 'sales_readonly', objects: {} }), - }); - await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_env')) - .rejects.toBeInstanceOf(PermissionDeniedError); - }); - - it('404s on an unknown id', async () => { - const ql = makeQl([declaredSet()]); - await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'nope')) - .rejects.toBeInstanceOf(PermissionSetNotFoundError); - }); - - it('409s when the package-declared set has no active overlay to discard', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'package', package_id: 'com.example.ehr', - ...permissionSetRowFields(artifact), - }); - // no overlay row - await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_1')) - .rejects.toBeInstanceOf(PermissionSetOverlayStateError); - }); - - it('refuses an unauthenticated / non-admin caller before touching anything', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'package', package_id: 'com.example.ehr', - ...permissionSetRowFields(artifact), - }); - ql.metaRows.push(overlayRow({ obj_a: {} })); - const nonAdminDeps: PermissionSetOverlayDiscardDeps = { - ...deps(ql), - resolveSets: async () => [], // not a tenant admin - }; - await expect(discardPermissionSetOverlay(nonAdminDeps, tenantAdminCtx, 'ps_1')) - .rejects.toBeInstanceOf(PermissionDeniedError); - expect(ql.metaRows).toHaveLength(1); // untouched - }); -}); - - -/* ------------------------------------------------------------------------- * - * pin 6 — the audit entry may never assert a write the store refused - * - * The defect: on the degraded-kernel branch (no metadata protocol) the resync - * `tryUpdate`'s result was discarded — not assigned, not tested. On refusal - * the row was re-read UNCHANGED, so `objectGrantsAfter` equalled - * `objectGrantsBefore` and the `info` line still announced a completed - * "sanctioned operator action". Every field was individually true; the entry - * as a whole was false. That is the one record that may not be optimistic. - * ------------------------------------------------------------------------- */ - -function recordingLogger() { - const info: any[] = []; - const warn: any[] = []; - const error: any[] = []; - return { - info, warn, error, - sink: { - info: (m: string, meta?: any) => { info.push({ m, meta }); }, - warn: (m: string, meta?: any) => { warn.push({ m, meta }); }, - error: (m: string, e?: any, meta?: any) => { error.push({ m, e, meta }); }, - }, - }; -} - -const connectionFailure = () => - Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); - -/** The degraded kernel this branch exists for: no metadata protocol at all. */ -function degradedDeps(ql: ReturnType, logger: any): PermissionSetOverlayDiscardDeps { - return { ...deps(ql), logger, getProtocol: () => undefined }; -} - -describe('discardPermissionSetOverlay — pin 6: a refused resync is never audited as a success', () => { - it('degraded kernel + REFUSED resync: the success line is WITHHELD and one entry stating the failure is emitted instead', async () => { - const artifact = declaredSet(); // ships 3 objects - const ql = makeQl([artifact], { refuseUpdatesWith: connectionFailure() }); - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'package', package_id: 'com.example.ehr', - ...permissionSetRowFields(artifact), - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), // 2 — stale - }); - ql.metaRows.push(overlayRow({ obj_a: {}, obj_b: {} })); - const log = recordingLogger(); - - const result = await discardPermissionSetOverlay(degradedDeps(ql, log.sink), tenantAdminCtx, 'ps_1'); - - // The destructive half DID land — which is why the entry is emitted with - // the failure stated rather than withheld outright. - expect(ql.metaRows).toHaveLength(0); - // The row was NOT healed: it still enforces its pre-discard grants. - expect(result.healedObjectGrantCount).toBe(2); - - // ⭐ No optimistic audit line, at any level. - expect(log.info.some((l) => l.m.includes('overlay discarded (sanctioned operator action)'))).toBe(false); - expect(log.warn.some((l) => l.m.includes('(sanctioned operator action)'))).toBe(false); - - // Exactly ONE entry, and it states what did and did not land. - expect(log.error).toHaveLength(1); - expect(log.error[0].m).toContain('RESYNC WRITE WAS REFUSED'); - expect(log.error[0].m).toContain('PARTIALLY applied'); - expect(log.error[0].meta).toMatchObject({ - id: 'ps_1', - name: 'ehr_quality_inspector', - by: 'u_admin', - overlaysDiscarded: 1, - objectGrantsBefore: 2, - objectGrantsAfter: 2, // un-healed, and the message says so - resyncWriteRefused: true, - }); - expect(log.error[0].meta.refusals[0]).toMatchObject({ object: 'sys_permission_set', class: 'other', count: 1 }); - expect(log.error[0].meta.refusals[0].driverCodes).toContain('ECONNREFUSED'); - }); - - it('⭐ counter-direction: the same degraded branch with a write the store ACCEPTS still emits the unchanged success line, and nothing on the failure channel', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); // updates land - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'package', package_id: 'com.example.ehr', - ...permissionSetRowFields(artifact), - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), - }); - ql.metaRows.push(overlayRow({ obj_a: {}, obj_b: {} })); - const log = recordingLogger(); - - const result = await discardPermissionSetOverlay(degradedDeps(ql, log.sink), tenantAdminCtx, 'ps_1'); - - expect(result.healedObjectGrantCount).toBe(3); // genuinely healed to the artifact - expect(log.error).toHaveLength(0); - const success = log.info.find((l) => l.m.includes('overlay discarded (sanctioned operator action)')); - expect(success).toBeDefined(); - expect(success!.meta).toMatchObject({ objectGrantsBefore: 2, objectGrantsAfter: 3, overlaysDiscarded: 1 }); - expect(success!.meta.resyncWriteRefused).toBeUndefined(); - }); - - it('against a REDUCED sink with no `error`, the refused resync still prints — at `warn`, never nowhere', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact], { refuseUpdatesWith: connectionFailure() }); - ql.permRows.push({ - id: 'ps_1', name: 'ehr_quality_inspector', managed_by: 'package', package_id: 'com.example.ehr', - ...permissionSetRowFields(artifact), - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - }); - ql.metaRows.push(overlayRow({ obj_a: {} })); - const warn: any[] = []; - - await discardPermissionSetOverlay( - degradedDeps(ql, { warn: (m: string, meta?: any) => { warn.push({ m, meta }); } }), - tenantAdminCtx, - 'ps_1', - ); - - expect(warn.some((l) => l.m.includes('RESYNC WRITE WAS REFUSED'))).toBe(true); - }); -}); - - -/* ------------------------------------------------------------------------- * - * [#21860] eligibility is the lock's classifier, not "an item carries a - * package id" - * - * The registry holds stored rows as well as artifacts, and the metadata list - * read stamps a stored row's `package_id` column onto its body as - * `_packageId`. A set saved into a WRITABLE RUNTIME package is therefore - * registered as `{ _packageId: , _provenance: 'org' }` after the - * first list read, and this action read the package id as "package-declared": - * it answered 200 and deleted the set's only `sys_metadata` row. The bodies - * below are the ones the hydrator registers for each shape (an org's own set - * and a clone carry the tenant marker and no package id). - * - * Every refusal asserts the envelope (`code` + `status`) AND that no row - * moved: both tables are counted before and after, and the stored rows are - * compared whole. - * ------------------------------------------------------------------------- */ - -const RUNTIME_PACKAGE = 'com.dogfood.runtime_pkg'; - -interface EnvShape { - label: string; - /** The body the registry holds for the set, as the hydrator registers it. */ - registered: Record; - /** The stored definition's `package_id` column (the runtime package, or none). */ - packageId: string | null; -} - -const envObjects = { obj_a: { allowRead: true } }; - -const ENV_SHAPES: EnvShape[] = [ - { - label: 'a set saved into a writable runtime package (after a list read stamped its package id)', - registered: { name: 'rt_pkg_set', label: 'Runtime package set', objects: envObjects, _packageId: RUNTIME_PACKAGE, _provenance: 'org' }, - packageId: RUNTIME_PACKAGE, - }, - { - label: "an org's own set", - registered: { name: 'org_owned_set', label: 'Org-owned set', objects: envObjects, _provenance: 'org' }, - packageId: null, - }, - { - label: 'a "Clone to customize" clone', - registered: { name: 'contributor_clone', label: 'Contributor (clone)', objects: envObjects, _provenance: 'org' }, - packageId: null, - }, - { - // The runtime shadow the lock module documents for a definition that lives - // only in `sys_metadata`: its package id is the `'sys_metadata'` sentinel, - // which the classifier excludes and a bare "has a package id" read accepts. - label: "a runtime shadow carrying the 'sys_metadata' sentinel package id", - registered: { name: 'runtime_only_set', label: 'Runtime-only set', objects: envObjects, _packageId: 'sys_metadata', _provenance: 'org' }, - packageId: null, - }, -]; - -/** The set's record and its one stored definition — the only copy of it there is. */ -function seedEnvShape(ql: ReturnType, shape: EnvShape): string { - const name = shape.registered.name; - const id = `ps_${name}`; - ql.permRows.push({ - id, name, managed_by: 'admin', package_id: shape.packageId, - object_permissions: JSON.stringify(envObjects), - }); - ql.metaRows.push({ - id: `meta_${name}`, type: 'permission', name, state: 'active', organization_id: null, - package_id: shape.packageId, - metadata: JSON.stringify({ name, label: shape.registered.label, objects: envObjects }), - }); - return id; -} - -const snapshot = (ql: ReturnType) => ({ - permissionSets: ql.permRows.length, - metadataRows: ql.metaRows.length, - rows: structuredClone({ perm: ql.permRows, meta: ql.metaRows }), -}); - -/** The shipped artifact of the control, with the envelope a code package's item carries. */ -const shippedArtifact = () => declaredSet({ _provenance: 'package' }); - -describe('[#21860] discardPermissionSetOverlay — eligibility is the lock classifier\'s verdict', () => { - for (const shape of ENV_SHAPES) { - it(`refuses ${shape.label} with 403 PERMISSION_DENIED, and every row is intact`, async () => { - // A code-shipped set sits beside it, so "some package ships something" - // is true of the registry: the verdict must be about THIS name. - const ql = makeQl([shippedArtifact(), shape.registered]); - const id = seedEnvShape(ql, shape); - const before = snapshot(ql); - - await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, id)) - .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); - - const after = snapshot(ql); - expect({ permissionSets: after.permissionSets, metadataRows: after.metadataRows }) - .toEqual({ permissionSets: before.permissionSets, metadataRows: before.metadataRows }); - expect(after.rows).toEqual(before.rows); - }); - } - - it('refuses when the classifier cannot answer (the registry read throws), with every row intact', async () => { - const shape = ENV_SHAPES[0]; - const ql = makeQl([shape.registered]); - ql.registry.listItems = () => { throw new Error('registry offline'); }; - const id = seedEnvShape(ql, shape); - const before = snapshot(ql); - - await expect(discardPermissionSetOverlay(deps(ql), tenantAdminCtx, id)) - .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); - - expect(snapshot(ql)).toEqual(before); - }); - - it('control: a code-shipped set\'s overlay is still discarded and the row healed to the artifact — beside a legacy overlay wearing the artifact\'s envelope in the registry', async () => { - const artifact = shippedArtifact(); // ships 3 objects - // A stored overlay of a name a code package ships is hydrated wearing the - // artifact's envelope (`_provenance: 'package'`), next to the artifact. - const hydratedOverlay = { name: artifact.name, objects: { obj_a: {} }, _packageId: artifact._packageId, _provenance: 'package' }; - const ql = makeQl([artifact, hydratedOverlay]); - ql.permRows.push({ - id: 'ps_1', name: artifact.name, managed_by: 'package', package_id: artifact._packageId, - ...permissionSetRowFields(artifact), - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - }); - ql.metaRows.push(overlayRow({ obj_a: {} })); - - const result = await discardPermissionSetOverlay(deps(ql), tenantAdminCtx, 'ps_1'); - - expect(result.overlaysDiscarded).toBe(1); - expect(ql.metaRows).toHaveLength(0); - expect(ql.permRows).toHaveLength(1); - expect(result.healedObjectGrantCount).toBe(3); - expect(Object.keys(JSON.parse(result.permissionSet.object_permissions)).sort()).toEqual(['obj_a', 'obj_b', 'obj_c']); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts deleted file mode 100644 index 198bb5d4688..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts +++ /dev/null @@ -1,353 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Sanctioned, audited operator action: discard a stale `sys_metadata` - * overlay shadowing a PACKAGE-DECLARED `sys_permission_set` (the - * `overlay_shadow` mechanism {@link ../permission-set-drift.ts} detects). - * - * Field report: the only remediation available was raw SQL against - * `sys_metadata` (`DELETE … WHERE type='permission' AND name='…'`), run by - * hand against production. This module is that same operation — the SAME - * physical row, the SAME table — as a supported, gated, audited code path - * instead. Maintainer ruling 2026-08-20 (「新项目还没上线,不需要清理旧数据, - * 也没有老客户升级」) scoped this DELIBERATELY NARROWLY: this is an - * operator-invoked, one-set-at-a-time, explicit mutation — never boot-time - * auto-adoption and never a bulk `os meta adopt-permission-sets` command - * (both stay out of scope). It never touches `managed_by` / `package_id`: - * once the overlay is gone, the EXISTING env-door reconciler - * (`reconcilePermissionSetProjection` step 2/3, ADR-0094 D4) keeps the row in - * sync with the artifact on every subsequent boot, on its own — no adoption - * needed for grant CONTENT to stay current, only for the row's provenance - * columns (out of scope, unaffected by this action). - * - * ## Why this bypasses `deleteMetaItem`, not calls it - * - * `permission` is `allowOrgOverride: false` since commit ee58392e1 (ADR-0094 - * D5-R), so the protocol's ADR-0005 tier gate refuses `deleteMetaItem` on an - * artifact-backed name with 403 `NOT_OVERRIDABLE` — see - * `permission-set-projection.ts`'s header, "leaving the operator hatch - * (`OS_METADATA_WRITABLE=permission`) as the only documented removal." That - * hatch is a blunt, environment-wide, undocumented-to-Setup escape valve — - * exactly what ask #2 exists to replace. This module deletes the - * `sys_metadata` overlay row DIRECTLY (SYSTEM context), never through - * `deleteMetaItem`, because the tier gate's refusal is correct in general - * (a data-door write must not silently re-fork a packaged set) and the - * discard action is a DIFFERENT, narrower operation the gate has no seat for: - * "remove a stale overlay and let the row re-converge to its declared - * artifact", never "write a new customization". - * - * ## Eligibility — pin: refuses what it must - * - * "Package-declared" means SHIPPED BY A CODE (artifact) PACKAGE, and it is - * decided by {@link classifyPackagedPermissionSet} — the classifier the - * write-door lock and the overlay detection reading already ask, over the - * engine SchemaRegistry `bootstrapDeclaredPermissions` seeds from — never by - * trusting the row's `managed_by` column. This is deliberate: `managed_by` is - * exactly the column the `provenance_skip` mechanism gets wrong, so gating on - * it would refuse the one case this action most needs to fix (a genuinely - * package-declared set whose row's `managed_by` was never 'package' to begin - * with — the field-reported shape, where BOTH mechanisms compounded on one - * row). Any other verdict is refused — the maintainer's cited hazard - * verbatim: "a name collision with a genuinely env-authored set would be - * destroyed without a trace". - * - * [#21860] ⛔ A package id on a registry item does NOT answer the question, - * and this action used to read it as if it did (`_packageId ?? packageId`). - * The registry holds stored rows as well as artifacts, and the metadata list - * read stamps a stored row's `package_id` column onto its body as - * `_packageId`, so a set saved into a WRITABLE RUNTIME package — the - * environment's own work, whose `sys_metadata` row is its only stored - * definition — read as package-declared after the first list read, and this - * action deleted that row. The classifier tells the two apart (a stored row is - * tenant-authored, an artifact is not); its `org` AND `unknown` verdicts both - * refuse here, because the safe direction for a destructive action is the - * reading's, not the write door's: a set this environment cannot PROVE a code - * package ships is never deleted. `permission-set-drift.ts` gates its declared - * population on the same verdict, so the diagnostic that names this action as - * the remedy never names it for a set the action refuses. - * - * ## The audit entry may never be optimistic - * - * This action's whole point is that it is AUDITED, so the one thing its log - * entry may not do is assert an action that did not fully land. The - * degraded-kernel branch below discarded its `tryUpdate` result, and on a - * refused resync every field of the success entry stayed individually true — - * the overlay count was right, `objectGrantsBefore`/`objectGrantsAfter` were - * both read off real rows — while the entry as a whole claimed a completed - * sanctioned operator action over a write the store had rejected. The result - * is now read, and a refused resync gets its own entry stating the failure - * INSTEAD of the success line (never alongside it): the overlay deletion DID - * land and must stay on the record, and the un-healed grant count must stop - * being reported as a healed one. What the CALLER is told is deliberately - * unchanged — see {@link PermissionSetOverlayDiscardResult}. - */ - -import type { PermissionSet } from '@objectstack/spec/security'; -import { - SYSTEM_CTX, - permissionSetRowFields, - projectPermissionMutation, - tryFind, - tryUpdate, - type ProjectionDeps, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - createSeedWriteRefusals, - logSeedDurabilityFailure, -} from './write-refusals.js'; -import { readDeclared } from './bootstrap-declared-permissions.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; -import { PermissionDeniedError } from './errors.js'; -import { isTenantAdmin } from './delegated-admin-gate.js'; - -/** - * Thrown when the referenced `sys_permission_set` row does not exist → HTTP 404. - * `NOT_FOUND` is listed under `ERROR_CODE_LEDGER['@objectstack/plugin-security']` - * (provenance row — this package stamps it). - */ -export class PermissionSetNotFoundError extends Error { - readonly code = 'NOT_FOUND'; - readonly statusCode = 404; - constructor(id: string) { - super(`Permission set '${id}' not found`); - this.name = 'PermissionSetNotFoundError'; - } -} - -/** - * Thrown when there is no active overlay to discard → HTTP 409. - * `INVALID_STATE` is listed under `ERROR_CODE_LEDGER['@objectstack/plugin-security']` - * (provenance row — this package stamps it). - */ -export class PermissionSetOverlayStateError extends Error { - readonly code = 'INVALID_STATE'; - readonly statusCode = 409; - constructor(message: string) { - super(message); - this.name = 'PermissionSetOverlayStateError'; - } -} - -export interface PermissionSetOverlayDiscardDeps { - ql: any; - metadata?: any; - /** Same tenant-admin resolution the confirm/dismiss suggestion flow uses. */ - resolveSets: (context: any) => Promise; - /** Lazy protocol handle — mirrors `WriteThroughDeps.getProtocol` (the protocol service may register after start()). */ - getProtocol: () => any; - logger?: ProjectionLogger; -} - -export interface PermissionSetOverlayDiscardResult { - permissionSet: any; - /** - * Object grants the row now carries (post-reconcile) — the "healed" number, - * for callers to assert on. - * - * ⚠️ Literally "what the row carries NOW", which is not always a healed - * count: on the degraded-kernel branch a REFUSED resync write leaves this - * equal to the pre-discard count. That case is reported on the audit - * channel (an entry naming the refusal replaces the success line) and - * deliberately NOT on this contract — propagating it to the caller would - * widen a declared surface and is out of scope here. - */ - healedObjectGrantCount: number; - overlaysDiscarded: number; -} - -function callerOrganizationId(callerCtx: any): string | undefined { - const id = callerCtx?.tenantId; - return typeof id === 'string' && id !== '' ? id : undefined; -} - -async function assertTenantAdmin(deps: PermissionSetOverlayDiscardDeps, callerCtx: any): Promise { - if (callerCtx?.isSystem) return; - if (!callerCtx?.userId) { - throw new PermissionDeniedError( - "[Security] Access denied: discarding a permission set's overlay requires an authenticated tenant administrator.", - ); - } - let sets: PermissionSet[] = []; - try { sets = await deps.resolveSets(callerCtx); } catch { sets = []; } - if (!isTenantAdmin(sets)) { - throw new PermissionDeniedError( - "[Security] Access denied: discarding a permission set's overlay requires a tenant-level administrator.", - { userId: callerCtx.userId }, - ); - } -} - -/** Every ACTIVE env-wide `sys_metadata` overlay row for `name` (both `permission`/`permissions` type spellings). */ -async function findActiveOverlayRows(ql: any, name: string): Promise { - const rows: any[] = []; - for (const type of ['permission', 'permissions']) { - const page = await tryFind(ql, 'sys_metadata', { type, name, state: 'active' }, 10); - for (const r of page) { - if ((r?.organization_id ?? null) !== null) continue; // env-wide overlays only (see permission-set-drift.ts) - rows.push(r); - } - } - return rows; -} - -function countGrantedObjects(row: any): number { - try { - const parsed = JSON.parse(row?.object_permissions ?? '{}'); - return parsed && typeof parsed === 'object' ? Object.keys(parsed).length : 0; - } catch { return 0; } -} - -/** - * Discard the stale overlay shadowing a package-declared permission set, - * then RESTORE SYNC synchronously — the row is re-projected to the current - * declared artifact before this returns (never "wait for the next boot"). - */ -export async function discardPermissionSetOverlay( - deps: PermissionSetOverlayDiscardDeps, - callerCtx: any, - id: string, -): Promise { - const { ql, logger } = deps; - await assertTenantAdmin(deps, callerCtx); - const organizationId = callerOrganizationId(callerCtx); - - const row = (await tryFind(ql, 'sys_permission_set', { id }, 1, organizationId))[0]; - if (!row) throw new PermissionSetNotFoundError(id); - - // Eligibility: shipped by a code package, decided by the lock's classifier - // over the ARTIFACT registry — never from `row.managed_by`, which is exactly - // the column the confounded provenance-skip + overlay-shadow case gets - // wrong, and never from "an item of this name carries a package id", which a - // set saved into a writable runtime package also does. See module header. - const verdict = classifyPackagedPermissionSet(String(row.name), ql); - if (verdict.status !== 'packaged') { - throw new PermissionDeniedError( - verdict.status === 'unknown' - ? `[Security] Access denied: this environment could not determine whether '${String(row.name)}' is ` + - `shipped by an installed code package (${verdict.reason}) — the discard is refused rather than risk ` + - `deleting environment-authored work with no trace and no recovery path. Retry once the metadata ` + - `layer is readable.` - : `[Security] Access denied: '${String(row.name)}' is not shipped by any installed code package — it ` + - `is this environment's own set (created here, cloned, or saved into a writable runtime package), and ` + - `discarding its stored definition would destroy environment-authored work with no trace and no ` + - `recovery path. This action targets sets a code package ships only.`, - { id, name: row.name }, - ); - } - // The declared body, for the degraded-kernel resync below. Picked among the - // items of a name the classifier has just called code-shipped, by the same - // selector as before, so for every set this action accepts the body and the - // audited package are exactly what they were. - const declaredItem = readDeclared(ql, 'permission').find( - (item: any) => item?.name === row.name && (item?._packageId ?? item?.packageId), - ); - - const overlays = await findActiveOverlayRows(ql, String(row.name)); - if (overlays.length === 0) { - throw new PermissionSetOverlayStateError( - `Permission set '${row.name}' has no active environment overlay to discard — its enforced grants are not ` + - `currently shadowed (if they still differ from the shipped artifact, this is the 'provenance_skip' ` + - `mechanism, which this action does not target).`, - ); - } - - const beforeCount = countGrantedObjects(row); - for (const overlay of overlays) { - try { - await ql.delete('sys_metadata', { where: { id: overlay.id }, context: SYSTEM_CTX }); - } catch (e) { - logger?.error?.( - '[security] failed to delete stale permission-set overlay row during sanctioned discard', - e as Error, - { name: row.name, overlayId: overlay.id }, - ); - throw e; - } - } - - // Restore sync NOW, not on next boot: re-run the SAME projector the env - // door awaits on every save (ADR-0094) — with the overlay gone, - // `getMetaItemLayered` now yields the declared artifact body, so this both - // re-projects the record's facets and heals the evaluator's in-memory - // registry echo (`syncEvaluatorRegistry`'s "overlay gone" branch). - const protocol = deps.getProtocol?.(); - const projectionDeps: ProjectionDeps = { ql, metadata: deps.metadata, logger }; - const refusals = createSeedWriteRefusals(); - let healedRow: any; - let resyncRefused = false; - if (protocol && typeof protocol.getMetaItemLayered === 'function') { - await projectPermissionMutation(protocol, projectionDeps, { - type: 'permission', name: String(row.name), state: 'active', - }); - healedRow = (await tryFind(ql, 'sys_permission_set', { id }, 1, organizationId))[0] ?? row; - } else { - // Degraded kernel with no metadata protocol: project the declared - // artifact's facets directly, same shape `upsertEnvPermissionSet` writes, - // clearing `customized` (there is no overlay left to badge). - // - // ⛔ The result is READ. Discarding it is what let the audit line below - // assert a completed operator action over a write the store refused: on - // refusal `healedRow` is re-read as the UNCHANGED row, `objectGrantsAfter` - // equals `objectGrantsBefore`, and every field of the entry stays - // individually true while the entry as a whole is false. An audit record - // of a sanctioned action is the one record that may not be optimistic. - resyncRefused = !(await tryUpdate( - ql, - 'sys_permission_set', - { id, ...permissionSetRowFields(declaredItem), customized: false }, - organizationId, - refusals, - )); - healedRow = (await tryFind(ql, 'sys_permission_set', { id }, 1, organizationId))[0] ?? row; - } - - const afterCount = countGrantedObjects(healedRow); - // Audited: who, what, before/after — the "supported, audited action" ask. - const audit = { - id, name: row.name, packageId: declaredItem._packageId ?? declaredItem.packageId, - by: callerCtx?.userId, organization: organizationId, - overlaysDiscarded: overlays.length, - objectGrantsBefore: beforeCount, objectGrantsAfter: afterCount, - }; - if (resyncRefused) { - // ⛔ EXACTLY ONE audit entry per action, and when the resync was refused - // it is THIS one — the success line above is withheld, never emitted - // alongside. Two entries for one action would leave a reader to decide - // which is authoritative, and the optimistic one is the one that reads - // like the rest of the ledger. - // - // Emitted-with-the-failure-stated rather than withheld outright, because - // the destructive HALF of this operator action DID land: the sys_metadata - // overlay row is deleted and gone (that leg rethrows, so reaching here - // means it succeeded). Withholding the entry would erase the record of a - // deletion that actually happened — a worse audit defect than the - // optimistic one this repairs. - // - // Durability channel (AGENTS.md "Degradation log levels"): the caller is - // answered normally by ruling, the API returns 200, and the row silently - // goes on enforcing its pre-discard grants — the "still looks normal from - // the outside" shape, which is `error` with the mandatory `warn` fallback - // for hosts that inject a sink without one. - logSeedDurabilityFailure( - logger, - `[security] package-declared permission set overlay discarded BUT THE RESYNC WRITE WAS REFUSED ` + - `(sanctioned operator action, PARTIALLY applied) — the stale sys_metadata overlay row(s) WERE ` + - `deleted and are gone, and the follow-up write that re-projects the declared artifact onto ` + - `sys_permission_set was refused by the store, so the row STILL ENFORCES ITS PRE-DISCARD GRANTS. ` + - `"objectGrantsAfter" below is therefore the UN-HEALED count, and the caller was answered with that ` + - `same number as "healedObjectGrantCount" and a 200 — NOTHING LOOKS BROKEN from the API. This entry ` + - `replaces the "overlay discarded" success line, which is NOT also emitted for this action. ` + - `Recovery: the overlay is gone, so the ADR-0094 env-door reconciler ` + - `("reconcilePermissionSetProjection") re-projects this row on its own on the NEXT BOOT — restart, ` + - `or re-run this action once the store accepts writes again, to converge sooner. What the store ` + - `actually said is in the query engine's "Update operation failed" entry logged just before this ` + - `one, which keeps the driver's own identifier with the bound statement and its values cut.`, - { ...audit, resyncWriteRefused: true, refusals: refusals.report() }, - ); - } else { - logger?.info?.('[security] package-declared permission set overlay discarded (sanctioned operator action)', audit); - } - - return { permissionSet: healedRow, healedObjectGrantCount: afterCount, overlaysDiscarded: overlays.length }; -} diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index e3129e783a5..9e91bba87ed 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -64,8 +64,7 @@ * That is NOT the parallel-allowlist failure Prime Directive #8 exists to * prevent: the lock does not re-derive `isArtifactBacked`, it reads the * engine SchemaRegistry — the one source this plugin already calls - * "package-declared" ({@link readDeclaredBody} here, - * `permission-set-overlay-discard.ts`'s eligibility test, and + * "package-declared" ({@link readDeclaredBody} here, and * `permission-set-drift.ts`'s declared sweep). The `managed_by` column * stays measurably NOT that fact and the lock still never reads it — * `member_default`'s row is `managed_by:'admin'`, `twodoors_pkgset`'s is @@ -1395,8 +1394,7 @@ export function createPermissionSetWriteThrough( // nothing an admin can act on. It is NOT a second copy of // `isArtifactBacked` (Prime Directive #8): it reads the engine // SchemaRegistry, the one source this plugin already calls - // "package-declared" in `permission-set-overlay-discard.ts` and - // `readDeclaredBody` above. See `packaged-permission-set-lock.ts`. + // "package-declared" in `readDeclaredBody` above. See `packaged-permission-set-lock.ts`. const layeredByName = new Map(); for (const row of targets) { const name = String(row.name); diff --git a/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts b/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts index 6f32d114b85..6d19cb1b79f 100644 --- a/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts +++ b/packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts @@ -16,7 +16,8 @@ * overlay discard the REST route calls, and the ownership-floor alternate seam * `service-storage` calls at boot. A contract reader could not see that either * seam existed, what it refused, or that a caller must feature-detect it. Both - * are declared now; this pin keeps the next one from going undeclared. + * were declared (the overlay discard has since retired with its route); this + * pin keeps the next one from going undeclared. * * ## How the declared list stays equal to the interface * @@ -47,7 +48,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { ISecurityService } from '@objectstack/spec/contracts'; import { SecurityPlugin } from './security-plugin.js'; -import { discardPermissionSetOverlay } from './permission-set-overlay-discard.js'; import { OwnershipFloorAlternates } from './ownership-floor-alternates.js'; /** `optional` exactly when the interface declares the member with `?`. */ @@ -77,7 +77,6 @@ const DECLARED_MEMBERS = { listAudienceBindingSuggestions: 'required', confirmAudienceBindingSuggestion: 'required', dismissAudienceBindingSuggestion: 'required', - discardPermissionSetOverlay: 'optional', contributeOwnershipFloorAlternates: 'optional', } as const satisfies DeclaredOptionality; @@ -196,18 +195,15 @@ describe('the registered `security` service serves only what ISecurityService de } }); - it('the two extension members are served with the signatures the contract declares (compile-time)', () => { - // The `Object.assign` half is not typed against the contract, so these two - // witnesses are: each is the contract's member type, implemented by - // delegating to the function the registered member delegates to. A + it('the extension member is served with the signature the contract declares (compile-time)', () => { + // The `Object.assign` half is not typed against the contract, so this + // witness is: the contract's member type, implemented by delegating to + // the function the registered member delegates to. A // parameter the contract hands over that the implementation cannot take, // or a result the implementation returns that the contract does not // promise, stops this file compiling. Never invoked. - const discard: NonNullable = (callerContext, id) => - discardPermissionSetOverlay(null as never, callerContext, id); const contribute: NonNullable = (plugin, alternates) => new OwnershipFloorAlternates().contribute(plugin, alternates); - expect(typeof discard).toBe('function'); expect(typeof contribute).toBe('function'); }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 83caa806b67..be11c8a3986 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -67,7 +67,6 @@ import { } from './permission-set-projection.js'; import { runPermissionSetDriftDiagnostics } from './permission-set-drift.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; -import { discardPermissionSetOverlay, type PermissionSetOverlayDiscardDeps } from './permission-set-overlay-discard.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; import { @@ -2034,20 +2033,6 @@ export class SecurityPlugin implements Plugin { resolveSets: (context: any) => this.resolvePermissionSetsForContext(context), logger: ctx.logger, }; - // [field report — rc→GA declared≠enforced surfacing] Sanctioned overlay- - // discard deps — same tenant-admin resolution as the suggestion surface - // above. `getProtocol` is lazy (mirrors `createPermissionSetWriteThrough`'s - // own resolver further down): the protocol service may register after - // this plugin's `start()` runs. - const overlayDiscardDeps: PermissionSetOverlayDiscardDeps = { - ql, - metadata, - resolveSets: (context: any) => this.resolvePermissionSetsForContext(context), - getProtocol: () => { - try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } - }, - logger: ctx.logger, - }; // Typed against the published contract so the registered surface cannot // drift from what cross-package consumers are promised: a renamed method, // a dropped one, or a changed return type fails THIS build rather than @@ -2288,16 +2273,10 @@ export class SecurityPlugin implements Plugin { const registeredSecurityService = Object.assign(securityService, { getMetadataReadableFields: (object: string, context?: any) => this.getMetadataReadableFields(object, context), - // [field report — rc→GA declared≠enforced surfacing] Same extension - // pattern as `getMetadataReadableFields` above: - // `ISecurityService` lives in `packages/spec` and this seat there is a - // separate change. Consumers feature-detect. - discardPermissionSetOverlay: (callerContext: any, id: string) => - discardPermissionSetOverlay(overlayDiscardDeps, callerContext, id), // [#21729] The ownership-floor alternate seam — how a plugin that // installs a tighter row gate stops the platform's `created_by` floor // pre-empting it, registered beside that gate and nowhere else. Same - // extension pattern as the two above (the contract lives in + // extension pattern as the one above (the contract lives in // `packages/spec`; a contributor feature-detects the method). Keyed by // the contributing plugin: a second call replaces the first, an empty // list withdraws. Throws on a contribution the seam refuses — see @@ -4776,8 +4755,9 @@ export class SecurityPlugin implements Plugin { // silently forked before the save door was locked: count + NAMES, // reported loudly. ⛔ Reaps nothing, merges nothing, migrates // nothing — disposition of an existing fork is a follow-up reading - // for the maintainer, and the per-set remedy is the explicit, - // audited "Discard Overlay" action a human invokes. + // for the maintainer. A deployment upgraded onto ADR-0048's + // cold-boot check holding such a fork does not boot at all; its + // remedy is the offline `os migrate security-catalog-overlays`. // // Distinct from the drift diagnostic above and deliberately run // beside it: drift reports a set whose grants ALREADY differ from diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index bad39837b69..3e0a1b60884 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -243,7 +243,7 @@ export const enObjects: NonNullable = { }, drift_status: { label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration — resync it with the Discard Overlay action. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", + help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", options: { overlay_shadow: "Overlay shadow", provenance_skip: "Provenance skip", @@ -288,11 +288,6 @@ export const enObjects: NonNullable = { confirmText: "Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.", successMessage: "Permission set deactivated" }, - discard_permission_set_overlay: { - label: "Discard Overlay", - confirmText: "Discard the environment overlay shadowing this package-declared set and resync it to the shipped artifact? This only removes the customization overlay — it never touches an environment-authored set.", - successMessage: "Overlay discarded — the set is resynced to the shipped artifact" - }, clone_permission_set: { label: "Clone", description: "Copies this set's permissions into a new organization-owned set you can edit. Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 3c9de31269f..bc5dd854ba3 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -243,7 +243,7 @@ export const esESObjects: NonNullable = { }, drift_status: { label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration — resync it with the Discard Overlay action. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", + help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", options: { overlay_shadow: "Overlay shadow", provenance_skip: "Provenance skip", @@ -288,11 +288,6 @@ export const esESObjects: NonNullable = { confirmText: "¿Desactivar este conjunto de permisos? Las asignaciones existentes se mantienen, pero dejan de otorgar acceso hasta que se vuelva a activar.", successMessage: "Conjunto de permisos desactivado" }, - discard_permission_set_overlay: { - label: "Discard Overlay", - confirmText: "Discard the environment overlay shadowing this package-declared set and resync it to the shipped artifact? This only removes the customization overlay — it never touches an environment-authored set.", - successMessage: "Overlay discarded — the set is resynced to the shipped artifact" - }, clone_permission_set: { label: "Clonar", description: "Copia los permisos de este conjunto en un nuevo conjunto propiedad de la organización que puedes editar. El alcance de administración delegada no se copia: concédelo deliberadamente en el nuevo conjunto si lo necesita.", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index 3aaa7e38117..e092e39af7d 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -18,13 +18,10 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._actions.discard_permission_set_overlay.confirmText": "02ce03bb6b6c58a2", - "objects.sys_permission_set._actions.discard_permission_set_overlay.label": "6523908dc7b796ed", - "objects.sys_permission_set._actions.discard_permission_set_overlay.successMessage": "938b7a7c989e5ff4", "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "b16445c654ee6cec", + "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index 10daf6b94d9..e14d57a5555 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -243,7 +243,7 @@ export const jaJPObjects: NonNullable = { }, drift_status: { label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration — resync it with the Discard Overlay action. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", + help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", options: { overlay_shadow: "Overlay shadow", provenance_skip: "Provenance skip", @@ -288,11 +288,6 @@ export const jaJPObjects: NonNullable = { confirmText: "この権限セットを無効化しますか?既存の割り当ては維持されますが、再度有効化するまでアクセスの付与は停止されます。", successMessage: "権限セットが無効化されました" }, - discard_permission_set_overlay: { - label: "Discard Overlay", - confirmText: "Discard the environment overlay shadowing this package-declared set and resync it to the shipped artifact? This only removes the customization overlay — it never touches an environment-authored set.", - successMessage: "Overlay discarded — the set is resynced to the shipped artifact" - }, clone_permission_set: { label: "複製", description: "このセットの権限を、編集可能な組織所有の新しい権限セットにコピーします。委任管理スコープはコピーされません。新しいセットに必要な場合は、個別に付与してください。", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index efdad4c9d70..e60a8172406 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -18,13 +18,10 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._actions.discard_permission_set_overlay.confirmText": "02ce03bb6b6c58a2", - "objects.sys_permission_set._actions.discard_permission_set_overlay.label": "6523908dc7b796ed", - "objects.sys_permission_set._actions.discard_permission_set_overlay.successMessage": "938b7a7c989e5ff4", "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "b16445c654ee6cec", + "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index c683a919ce0..ede2c70af8a 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -243,7 +243,7 @@ export const zhCNObjects: NonNullable = { }, drift_status: { label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration — resync it with the Discard Overlay action. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", + help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", options: { overlay_shadow: "Overlay shadow", provenance_skip: "Provenance skip", @@ -288,11 +288,6 @@ export const zhCNObjects: NonNullable = { confirmText: "确定要停用此权限集吗?现有分配仍将保留,但在重新激活之前将不再授予访问权限。", successMessage: "权限集已停用" }, - discard_permission_set_overlay: { - label: "Discard Overlay", - confirmText: "Discard the environment overlay shadowing this package-declared set and resync it to the shipped artifact? This only removes the customization overlay — it never touches an environment-authored set.", - successMessage: "Overlay discarded — the set is resynced to the shipped artifact" - }, clone_permission_set: { label: "克隆", description: "将此权限集的权限复制到一个新的、由组织拥有且可编辑的权限集。委派管理范围不会被复制——如果新权限集需要,请单独在其上授予。", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index e4f35207f16..64766ab31ac 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -18,13 +18,10 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._actions.discard_permission_set_overlay.confirmText": "02ce03bb6b6c58a2", - "objects.sys_permission_set._actions.discard_permission_set_overlay.label": "6523908dc7b796ed", - "objects.sys_permission_set._actions.discard_permission_set_overlay.successMessage": "938b7a7c989e5ff4", "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "b16445c654ee6cec", + "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", diff --git a/packages/plugins/plugin-security/src/write-refusals.ts b/packages/plugins/plugin-security/src/write-refusals.ts index ca3ce97fa85..090703c15ed 100644 --- a/packages/plugins/plugin-security/src/write-refusals.ts +++ b/packages/plugins/plugin-security/src/write-refusals.ts @@ -80,12 +80,11 @@ export type SeedLogger = { * * EXPORTED rather than module-private, for the reason its own doc gives — the * fallback "lives in {@link logSeedDurabilityFailure} so no site can forget - * it". Two sites outside the catalog seed now report a refused write and owe + * it". One site outside the catalog seed reports a refused write and owes * the identical fallback: `permission-set-drift.ts` (a refused drift-diagnostic - * write) and `permission-set-overlay-discard.ts` (a refused resync after a - * sanctioned overlay discard). They reuse this spelling and NOT - * {@link reportSeedWriteRefusals}, whose PROSE is catalog-seed-specific — see - * the deviation note in each of those call sites. Deliberately absent from the + * write). It reuses this spelling and NOT {@link reportSeedWriteRefusals}, + * whose PROSE is catalog-seed-specific — see the deviation note at that call + * site. Deliberately absent from the * package's `index.ts`: this is an intra-package helper, not public API. */ export function logSeedDurabilityFailure( diff --git a/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts deleted file mode 100644 index 99efbff7330..00000000000 --- a/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21860] The Discard Overlay action deletes stored permission-set rows only -// for a set a code (artifact) package ships, judged by the same classifier the -// write doors use, and the drift report judges the same population. Over the -// real showcase composition, across a cold boot on one database file. -// -// ## What was broken -// -// `POST /api/v1/security/permission-sets/:id/discard-overlay` decided "is this -// set package-declared?" by asking whether any registry item of the set's name -// carried a package id. The registry holds stored rows as well as artifacts, -// and the metadata list read (`GET /meta/permission`) stamps a stored row's -// `package_id` column onto its body as `_packageId`. So a set saved into a -// writable runtime package read as package-declared after the first list read: -// the action answered 200 and deleted the set's only `sys_metadata` row. Its -// declared contract, repeated on the permission-sets page of the docs, is that -// it refuses any set that is not package-declared, so it can never -// destroy an environment-authored set. The drift report read the package id the -// same way, and its `overlay_shadow` detail names this action as the remedy. -// -// ## The pins hold both directions -// -// The three environment-authored shapes (a runtime-package set, an org's own -// set, a clone) are made through their real doors and each is refused with the -// action's existing refusal, `403 PERMISSION_DENIED`, with its rows counted -// before and after. The control is a set the showcase package ships, given a -// legacy overlay: the action still discards that overlay and heals the record -// to the shipped artifact. -// -// ## Why a booted stack, booted twice — and the legacy overlay written after it -// -// The legacy overlay cannot be minted through a door any more (the lock refuses -// both), so it is written straight into `sys_metadata` the way an older release -// left it. It is written into the RUNNING second boot, not before it: since -// ADR-0048 addendum N.3 (ruling letter A on #22307) a cold boot whose -// environment catalog holds a package-held permission-set name is refused -// (`security-catalog-cold-boot-environment-holder.dogfood.test.ts` pins that), -// so a deployment carrying this row runs the action on the release before the -// upgrade, or not at all. The two passes the boot runs for it at `kernel:ready` -// are then run on it, by the functions the security plugin's boot calls: -// `reconcilePermissionSetProjection` projects it onto the record, so the record -// enforces the overlay's grants, and the drift pass reports the set as -// `overlay_shadow` — the field shape the action exists for. The list read that -// stamps the runtime package's id is issued after the cold boot, and a -// precondition asserts the stamp before any refusal is read: without it a -// refusal would prove nothing. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { - securityObjects, - computePermissionSetDriftDiagnostics, - persistPermissionSetDriftDiagnostics, - reconcilePermissionSetProjection, -} from '@objectstack/plugin-security'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const API_BASE = '/api/v1'; - -/** A writable runtime package, created through `POST /packages`. */ -const PKG = 'com.dogfood.discard21860'; -/** Shape 1 — a set saved into that runtime package. */ -const PKG_SET = 'discard21860_pkgset'; -/** Shape 2 — a set the org created through the data door. */ -const ORG_SET = 'discard21860_orgset'; -/** Shape 3 — a "Clone to customize" clone of a packaged set. */ -const CLONE = 'discard21860_clone'; -/** The control: a set the showcase package ships. */ -const SHIPPED = 'showcase_contributor'; -/** The legacy overlay's grants: fewer objects than the shipped artifact grants. */ -const OVERLAY_OBJECTS = { showcase_task: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }; - -interface Shape { label: string; name: string } -const SHAPES: Shape[] = [ - { label: 'runtime-package set', name: PKG_SET }, - { label: 'org-owned set', name: ORG_SET }, - { label: 'clone', name: CLONE }, -]; - -interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } -interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } - -/** The shipped Clone action, read off the object the security plugin registers. */ -function cloneAction(): CloneAction { - const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); - const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); - return action as CloneAction; -} - -const grantedObjects = (row: any): string[] => { - try { return Object.keys(JSON.parse(row?.object_permissions ?? '{}')).sort(); } catch { return []; } -}; - -describe('[#21860] Discard Overlay refuses every set no code package ships, with every row intact, and still discards a shipped set\'s overlay (showcase)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - let shippedArtifactObjects: string[]; - - const registryRows = (name: string): any[] => - (ql.registry.listItems('permission') ?? []).filter((i: any) => i?.name === name); - - /** Both tables, for one name: the record(s) and the stored definition row(s), whole. */ - const rowsOf = async (name: string) => { - const records = await ql.find('sys_permission_set', { where: { name } }, SYS); - const stored: any[] = []; - for (const type of ['permission', 'permissions']) { - stored.push(...(await ql.find('sys_metadata', { where: { type, name } }, SYS))); - } - return { - records: (records as any[]).map((r) => r.id).sort(), - stored: stored.map((r) => ({ id: r.id, state: r.state, metadata: r.metadata })).sort((a, b) => (a.id < b.id ? -1 : 1)), - }; - }; - - const discard = async (name: string) => { - const [row] = await ql.find('sys_permission_set', { where: { name }, limit: 1 }, SYS); - expect(row?.id, `the ${name} record exists`).toBeTruthy(); - const res = await stack!.apiAs(token, 'POST', `/security/permission-sets/${row.id}/discard-overlay`, {}); - return { status: res.status, json: (await res.json().catch(() => ({}))) as any }; - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21860-')); - dbFile = join(dir, 'showcase.db'); - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // Shape 1: a writable runtime package, and a set saved into it. - const pkg = await stack.apiAs(token, 'POST', '/packages', { - manifest: { id: PKG, name: 'Discard eligibility probe', version: '0.1.0', type: 'app' }, - }); - expect(pkg.status, JSON.stringify(await pkg.clone().json().catch(() => ({})))).toBe(201); - const saved = await stack.apiAs(token, 'PUT', `/meta/permission/${PKG_SET}?package=${PKG}`, { - name: PKG_SET, - label: 'Runtime package set', - objects: { showcase_task: { allowRead: true } }, - }); - expect(saved.status, JSON.stringify(await saved.clone().json().catch(() => ({})))).toBe(200); - - // Shape 2: the org's own set, created through the data door. - const created = await stack.apiAs(token, 'POST', '/data/sys_permission_set', { - name: ORG_SET, - label: 'Org-owned set', - object_permissions: JSON.stringify({ showcase_task: { allowRead: true } }), - }); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - - // Shape 3: a clone, made the way the Setup dialog makes it — the payload - // is built from the shipped action's own declaration. - const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); - shippedArtifactObjects = grantedObjects(base); - const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); - const served: any = await read.json(); - const row = served?.record ?? served?.data?.record ?? served?.data; - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params) { - if (p.name === 'label') body.label = 'Contributor (clone)'; - else if (p.name === 'name') body.name = CLONE; - else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; - } - expect(action.target.startsWith(API_BASE), action.target).toBe(true); - const cloned = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); - expect(cloned.status, JSON.stringify(await cloned.clone().json().catch(() => ({})))).toBe(201); - - await stack.stop(); - - // The cold boot. - stack = undefined; - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // The producer the card names: the list read a Studio page load issues. - const list = await stack.apiAs(token, 'GET', '/meta/permission'); - expect(list.status).toBe(200); - - // The control's legacy overlay, as an older release left it: an active, - // environment-wide stored definition of a name the package ships — - // written into the running deployment (see the header for why not - // before the cold boot), and made a real overlay by the two passes the - // security plugin's boot runs for it. - const now = new Date().toISOString(); - await ql.insert('sys_metadata', { - type: 'permission', - name: SHIPPED, - organization_id: null, - package_id: null, - state: 'active', - version: 1, - checksum: null, - created_at: now, - updated_at: now, - metadata: JSON.stringify({ name: SHIPPED, label: 'Showcase Contributor (legacy overlay)', objects: OVERLAY_OBJECTS }), - }, SYS); - const protocol = await stack.kernel.getServiceAsync('protocol'); - await reconcilePermissionSetProjection(protocol, { ql, metadata: await stack.kernel.getServiceAsync('metadata') }); - await persistPermissionSetDriftDiagnostics(ql, await computePermissionSetDriftDiagnostics(ql)); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('precondition: the list read stamped the runtime package onto its set\'s registry row, as a stored row', () => { - // ⛔ Without this, a refusal below proves nothing: a list read that - // stopped stamping the package id would also leave the set refused. - const rows = registryRows(PKG_SET); - expect(rows.map((r) => ({ _packageId: r._packageId ?? null, _provenance: r._provenance ?? null }))) - .toEqual([{ _packageId: PKG, _provenance: 'org' }]); - }); - - it('precondition: the shipped set\'s record enforces the legacy overlay, and the drift pass reported it overlay_shadow', async () => { - const [record] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); - expect(grantedObjects(record)).toEqual(Object.keys(OVERLAY_OBJECTS)); - expect(shippedArtifactObjects.length).toBeGreaterThan(Object.keys(OVERLAY_OBJECTS).length); - expect(record?.drift_status).toBe('overlay_shadow'); - expect((await rowsOf(SHIPPED)).stored).toHaveLength(1); - }); - - for (const shape of SHAPES) { - it(`${shape.label}: Discard Overlay is refused with 403 PERMISSION_DENIED, and every row is intact`, async () => { - const before = await rowsOf(shape.name); - // The stored definition the old reading deleted is there to lose. - expect(before.records).toHaveLength(1); - expect(before.stored.length).toBeGreaterThanOrEqual(1); - - const res = await discard(shape.name); - expect({ status: res.status, code: res.json?.error?.code }, JSON.stringify(res.json)) - .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); - - const after = await rowsOf(shape.name); - expect({ records: after.records.length, stored: after.stored.length }) - .toEqual({ records: before.records.length, stored: before.stored.length }); - expect(after).toEqual(before); - }); - } - - it('the drift report judges the same population: the shipped set is judged, none of the three shapes is', async () => { - const judged = new Set((await computePermissionSetDriftDiagnostics(ql)).map((d) => d.name)); - expect({ - shipped: judged.has(SHIPPED), - [PKG_SET]: judged.has(PKG_SET), - [ORG_SET]: judged.has(ORG_SET), - [CLONE]: judged.has(CLONE), - }).toEqual({ shipped: true, [PKG_SET]: false, [ORG_SET]: false, [CLONE]: false }); - }); - - it('control: a shipped set\'s legacy overlay is still discarded, and the record heals to the shipped artifact', async () => { - const before = await rowsOf(SHIPPED); - const res = await discard(SHIPPED); - expect(res.status, JSON.stringify(res.json)).toBe(200); - expect(res.json?.data?.overlaysDiscarded).toBe(1); - - const after = await rowsOf(SHIPPED); - expect({ records: after.records, stored: after.stored.length }).toEqual({ records: before.records, stored: 0 }); - const [record] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); - expect(grantedObjects(record)).toEqual(shippedArtifactObjects); - }); -}); diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 4def040e9f4..b5253bbd213 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -318,7 +318,12 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('63 invocation sites, 78 mentions — the thread\'s two control numbers hold', () => { + it('62 invocation sites, 77 mentions — the thread\'s two control numbers hold', () => { + // [ADR-0131 cutover, stage 7-pre] 63 → 62 sites / 78 → 77 mentions: + // the permission-set overlay-discard route retired, taking one BARE + // site (47 → 46) with it and no prose mention. The caught half (16) + // is untouched. + // // [ADR-0131 D6 + #22430, merged] 68 → 63 sites / 92 → 78 mentions: // #22430's two new BARE sites and ADR-0131 D6's five removed CAUGHT // sites, landed on parallel branches and composed at the merge. The @@ -481,11 +486,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // naming the seam is the point of the sentence — and the sentence // moving only the mention count is this control working: a site was not // added, and the number that tracks sites did not move. - expect(SITES.length).toBe(63); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(78); + expect(SITES.length).toBe(62); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(77); }); - it('the split is 16 locally caught / 47 bare — NOT 12 / 47, which does not add to 63', () => { + it('the split is 16 locally caught / 46 bare — NOT 12 / 46, which does not add to 62', () => { // 12 sites spell the catch on the invocation line; 4 more spell it on // the continuation line. A single-line grep sees 12 and the arithmetic // silently loses four sites. [#20237] 15 → 13 and 23 → 21: the list @@ -499,27 +504,27 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch(')); expect(sameLine.length).toBe(12); expect(CAUGHT.length).toBe(16); - expect(BARE.length).toBe(47); + expect(BARE.length).toBe(46); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 47 bare sites is guarded on the VERY NEXT LINE, and none of the 16 caught ones is', () => { + it('⭐ every one of the 46 bare sites is guarded on the VERY NEXT LINE, and none of the 16 caught ones is', () => { // This inverts the reason the thread gave for doing the bare sites // first ("no local signal that a fault becomes an anonymous subject"). // The bare sites are bare BECAUSE the shared anonymous floor is the // next statement; the locally-caught ones carry a `.catch` because // they are NOT behind that floor and each must decide for itself. - expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(47); + expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(46); expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0); }); }); // --------------------------------------------------------------------------- -// 3. The 47 bare sites, driven +// 3. The 46 bare sites, driven // --------------------------------------------------------------------------- -describe('[#13160] §3 the 47 bare sites — driven, every one of them', () => { - it('all 47 are reached by the mounted route table, so none is classified by inference', async () => { +describe('[#13160] §3 the 46 bare sites — driven, every one of them', () => { + it('all 46 are reached by the mounted route table, so none is classified by inference', async () => { const reached = sitesOf(await sweep(undefined, 'FULL')); const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l)); // ⛔ A bare site that stopped being reachable must show up as a @@ -527,14 +532,14 @@ describe('[#13160] §3 the 47 bare sites — driven, every one of them', () => { expect(unreached).toEqual([]); }, 120_000); - it('an absent context is the ANONYMOUS SUBJECT at all 47: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { + it('an absent context is the ANONYMOUS SUBJECT at all 46: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => { const fault = await sweep(undefined, 'FULL'); const control = await sweep(ENTITLED, 'FULL'); const bareLines = new Set(BARE.map((s) => s.line)); const controlByRoute = new Map(control.map((r) => [r.route, r])); const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l))); - expect(rows.length).toBeGreaterThanOrEqual(47); + expect(rows.length).toBeGreaterThanOrEqual(46); for (const row of rows) { expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 6d8788e1c9a..fd364d05c99 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -418,13 +418,11 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'duplicate mount with the dispatcher /security domain' }, { route: 'POST /api/v1/security/suggested-bindings/:id/dismiss', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.dismiss', note: 'duplicate mount with the dispatcher /security domain' }, - // [field report — rc→GA declared≠enforced surfacing] REST-only (no - // dispatcher twin, unlike the suggested-bindings family above): invoked by - // `sys_permission_set`'s "Discard Overlay" Setup action via a plain fetch - // to `target`, not through the typed SDK — see - // `permission-set-overlay-discard.ts`. - { route: 'POST /api/v1/security/permission-sets/:id/discard-overlay', family: 'security', source: 'route-manager', disposition: 'server-only', - note: 'Setup admin action only (sys_permission_set "Discard Overlay") — invoked via the declarative action target, not the SDK' }, + // RETIRED (ADR-0131 cutover): `POST /api/v1/security/permission-sets/:id/ + // discard-overlay` left with the "Discard Overlay" Setup action. No door + // creates an environment overlay of a packaged permission set any more, and + // a database holding one is refused at cold boot (ADR-0048), so the offline + // `os migrate security-catalog-overlays` is its remedy; no row, no route. // ── reports ── RETIRED (#20102): the eight saved-report rows left with // their routes and the SDK's `reports` namespace; no row, no route, no method. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 750f77b63de..d6062293ecb 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -12367,45 +12367,6 @@ export class RestServer { }, metadata: { summary: 'Dismiss a suggested audience binding', tags: ['security'] }, }); - - /** - * [field report — rc→GA declared≠enforced surfacing] The sanctioned, - * audited operator action: discard a stale `sys_metadata` overlay - * shadowing a package-declared `sys_permission_set` (the - * `overlay_shadow` diagnostic on the record — see - * `permission-set-drift.ts`). Bound to `sys_permission_set`'s - * "Discard Overlay" Setup action (`{id}` route param, same - * convention as `/data/sys_permission_set/{id}`). Refuses (403) on a - * set that is not currently package-declared — see - * `permission-set-overlay-discard.ts`'s eligibility note — and 409s - * when there is no active overlay to discard. - * - * POST {basePath}/security/permission-sets/:id/discard-overlay - */ - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/security/permission-sets/:id/discard-overlay`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc || typeof svc.discardPermissionSetOverlay !== 'function') return respond501(res); - const result = await svc.discardPermissionSetOverlay(context ?? {}, String(req.params.id)); - res.json({ data: result }); - // 'INTERNAL' (registered, generic — ADR-0112) is the default here - // deliberately, not a bespoke `..._FAILED` code: this route's typed - // errors (`PermissionSetNotFoundError` 404, `PermissionSetOverlayStateError` - // 409, `PermissionDeniedError` 403) already carry their own registered - // `code`, which `handleError`'s `status !== 500` arm reads ahead of this - // default — this string is reached only by a genuinely unexpected fault, - // and a NEW route-specific code for that arm is a `packages/spec` ledger - // entry this change deliberately does not make. - } catch (err: any) { handleError(err, res, 'INTERNAL'); } - }, - metadata: { summary: 'Discard a stale environment overlay shadowing a package-declared permission set (ADR-0094)', tags: ['security'] }, - }); } /** diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index d6083c3809f..208f86d5346 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -383,13 +383,6 @@ const DISPOSITIONS: Record = { 'POST /api/v1/security/suggested-bindings/:id/confirm': { kind: 'no-record-echo', why: 'Binding receipt.' }, 'POST /api/v1/security/suggested-bindings/:id/dismiss': { kind: 'no-record-echo', why: 'Dismissal receipt.' }, 'POST /api/v1/security/explain': { kind: 'no-record-echo', why: 'Permission explanation verdict.' }, - // Discard-overlay receipt: `healedObjectGrantCount` / `overlaysDiscarded` - // plus the resynced `sys_permission_set` row for the caller's convenience. - // Same shape as confirm/dismiss above — the row travels through - // `permission-set-overlay-discard.ts`'s own `tryFind` (a `ql.find` READ, - // already engine-stripped), never a raw insert/update return, so there is - // no unstripped write mouth here to begin with. - 'POST /api/v1/security/permission-sets/:id/discard-overlay': { kind: 'no-record-echo', why: 'Overlay-discard receipt; the row rides a post-write read (ql.find), not a raw write-mouth echo.' }, // Import: per-row RECEIPTS (row number, action, id, warnings/errors) plus // counts — measured, the written row is never spread into a result. diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index 9bca79d50e6..991d55d4919 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -313,10 +313,9 @@ const ANSWERS_THE_WRAPPER: Record = { 'POST /api/v1/sharing/rules': { called: 'sharingRules.defineRule', status: 500, body: {} }, 'DELETE /api/v1/sharing/rules/:idOrName': { called: 'sharingRules.deleteRule', status: 500 }, 'POST /api/v1/sharing/rules/:idOrName/evaluate': { called: 'sharingRules.evaluateRule', status: 500 }, - // Suggested bindings and the overlay discard: the 500 arm relays `.message`. + // Suggested bindings: the 500 arm relays `.message`. 'POST /api/v1/security/suggested-bindings/:id/confirm': { called: 'security.confirmAudienceBindingSuggestion', status: 500 }, 'POST /api/v1/security/suggested-bindings/:id/dismiss': { called: 'security.dismissAudienceBindingSuggestion', status: 500 }, - 'POST /api/v1/security/permission-sets/:id/discard-overlay': { called: 'security.discardPermissionSetOverlay', status: 500 }, // Approvals: no `handleApprovalError` prefix matches, so the outer 500 relays `.message`. 'POST /api/v1/approvals/requests/:id/approve': { called: 'approvals.decide', status: 500, body: {} }, 'POST /api/v1/approvals/requests/:id/reject': { called: 'approvals.decide', status: 500, body: {} }, @@ -354,7 +353,7 @@ describe('[#22694] §1 the REST ledger\'s write rows, each with exactly one disp const maps = [Object.keys(ANSWERS_THE_SENTENCE), Object.keys(ANSWERS_THE_WRAPPER), [...NO_REFUSAL_REACHES_THE_ANSWER.keys()]]; const named = maps.flat(); const ledgered = WRITE_ROWS.map((row) => row.route); - expect(ledgered.length).toBeGreaterThanOrEqual(45); + expect(ledgered.length).toBeGreaterThanOrEqual(44); for (const route of ledgered) { expect(named.filter((r) => r === route), `no single disposition for ${route}`).toHaveLength(1); } diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index bcec4d90b5f..ff83320a448 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -219,7 +219,6 @@ "OwnershipFloorAlternate (interface)", "PendingActionRow (interface)", "PendingActionStatus (type)", - "PermissionSetOverlayDiscardResult (interface)", "PlanUpgradeInput (interface)", "Plugin (interface)", "PluginStartupResult (type)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 533eb49dd30..18682790ffc 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -219,7 +219,6 @@ "OwnershipFloorAlternate": "src/contracts/security-service.ts#OwnershipFloorAlternate (interface)", "PendingActionRow": "src/contracts/ai-service.ts#PendingActionRow (interface)", "PendingActionStatus": "src/contracts/ai-service.ts#PendingActionStatus (type)", - "PermissionSetOverlayDiscardResult": "src/contracts/security-service.ts#PermissionSetOverlayDiscardResult (interface)", "PlanUpgradeInput": "src/contracts/package-service.ts#PlanUpgradeInput (interface)", "Plugin": "src/contracts/plugin-validator.ts#Plugin (interface)", "PluginStartupResult": "src/kernel/startup-orchestrator.zod.ts#PluginStartupResult (type)", diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index 736fd85cd80..f7c169f9ea7 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -266,8 +266,6 @@ describe('ErrorCode (standard ∪ registered)', () => { // class field (`readonly code = '…'`) — the spelling the provenance gate // declares itself blind to, so this suite is what holds the rows. const stamps: Record = { - INVALID_STATE: '@objectstack/rest', // PermissionSetOverlayStateError, 409 - NOT_FOUND: '@objectstack/rest', // PermissionSetNotFoundError, 404 NOT_OVERRIDABLE: '@objectstack/metadata-protocol', // PackagedPermissionSet{Locked,ProvenanceUnknown}Error, 403 }; for (const [code, firstOwner] of Object.entries(stamps)) { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index bde983d345a..a1b13c2c44a 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1187,19 +1187,6 @@ export const ERROR_CODE_LEDGER = { // per this file's header a code emitted by several packages is listed // once per emitting package — provenance, not identity — so the union, // its casing and every other package's rows are unchanged. - // - // `PermissionSetOverlayStateError` (`permission-set-overlay-discard.ts`), - // 409: `discardPermissionSetOverlay` found no active overlay to discard. - // Served by `@objectstack/rest`'s `POST …/security/permission-sets/:id/ - // discard-overlay` route, whose `handleError` reads the thrown `code` - // ahead of its `INTERNAL` default. - 'INVALID_STATE', - // `PermissionSetNotFoundError` (`permission-set-overlay-discard.ts`), - // 404: the addressed `sys_permission_set` row does not exist. Same route - // and door as the row above. A waived synonym of `RESOURCE_NOT_FOUND` - // (`STANDARD_SYNONYM_WAIVERS` admits it per code, not per package — this - // row widens that waiver's emitter list, never the waiver table). - 'NOT_FOUND', // `PackagedPermissionSetLockedError` and // `PackagedPermissionSetProvenanceUnknownError` // (`packaged-permission-set-lock.ts`), 403: a data-path write targets a @@ -1697,10 +1684,8 @@ export const STANDARD_SYNONYM_WAIVERS: readonly StandardSynonymWaiver[] = [ { code: 'NOT_FOUND', shadows: 'RESOURCE_NOT_FOUND', - reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing; ' + - 'plugin-security lists the same pre-existing wire value under its own provenance row ' + - '(its permission-set overlay-discard 404). Wire value kept; consolidation deferred until ' + - 'it has a measured victim.', + reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing. ' + + 'Wire value kept; consolidation deferred until it has a measured victim.', }, { code: 'UNAUTHORIZED', diff --git a/packages/spec/src/contracts/security-service.test.ts b/packages/spec/src/contracts/security-service.test.ts index 5ede5c1a8a5..5da016f8f08 100644 --- a/packages/spec/src/contracts/security-service.test.ts +++ b/packages/spec/src/contracts/security-service.test.ts @@ -563,41 +563,6 @@ describe('Security Service Contract', () => { expect(seen[0]).toEqual({ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' }); }); - it('discardPermissionSetOverlay is OPTIONAL — absence is typed, and the caller answers it as not implemented', async () => { - // A security service without the operator action still satisfies the - // contract, and a caller cannot reach the action without handling the - // absent case first. - const withoutIt: ISecurityService = makeService(); - expect(typeof withoutIt.discardPermissionSetOverlay).toBe('undefined'); - - // The unguarded call does not compile. Never invoked: its only job is to - // make the COMPILER prove the point. - const mustNotCompileWithoutAGuard = () => - // @ts-expect-error possibly undefined — a caller must feature-detect first - withoutIt.discardPermissionSetOverlay({ userId: 'admin' }, 'ps_1'); - expect(typeof mustNotCompileWithoutAGuard).toBe('function'); - - // The shape the REST route writes: an absent method is a 501, never a - // pretended success. - const route = async (svc: ISecurityService) => - typeof svc.discardPermissionSetOverlay === 'function' - ? { status: 200, data: await svc.discardPermissionSetOverlay({ userId: 'admin' }, 'ps_1') } - : { status: 501, data: undefined }; - await expect(route(withoutIt)).resolves.toEqual({ status: 501, data: undefined }); - - const withIt = makeService({ - discardPermissionSetOverlay: async (_context, id) => ({ - permissionSet: { id, name: 'sales_user' }, - healedObjectGrantCount: 3, - overlaysDiscarded: 1, - }), - }); - await expect(route(withIt)).resolves.toEqual({ - status: 200, - data: { permissionSet: { id: 'ps_1', name: 'sales_user' }, healedObjectGrantCount: 3, overlaysDiscarded: 1 }, - }); - }); - it('contributeOwnershipFloorAlternates is OPTIONAL — absence is typed, and an alternate names exactly one floor limb', () => { // A security service without the seam still satisfies the contract. // Absence leaves the floor in force, which is the fail-closed direction: diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 72020ac0657..4a88a4e3d87 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -241,35 +241,6 @@ export type DelegationNarrowing = delegatorScope: ObjectAccessScope; }; -/** - * What {@link ISecurityService.discardPermissionSetOverlay} resolves with. - * - * The minimal contract shape. The reference implementation's own result type - * (`PermissionSetOverlayDiscardResult` in `@objectstack/plugin-security`) is - * plugin-internal and is assignable to this one; it types the row loosely. - */ -export interface PermissionSetOverlayDiscardResult { - /** - * The `sys_permission_set` row as stored once the overlay is gone and the row - * has been re-projected from the declared artifact. The column set is owned - * by the implementation's backing object rather than this contract, so it - * stays open, as {@link AudienceBindingSuggestion} does. - */ - permissionSet: Record; - /** - * The object grants the row carries NOW, read back after the re-projection. - * - * ⚠️ Not always a healed count. When the store refuses the re-projection - * write, the row keeps its pre-discard grants and this number equals the - * pre-discard count. The call still resolves, because the overlay deletion did - * land; the implementation reports the refused write on its audit log, not - * here. - */ - healedObjectGrantCount: number; - /** How many overlay rows were deleted. At least 1 on any call that resolves. */ - overlaysDiscarded: number; -} - /** * One alternate match a plugin contributes to the platform's row-level write * ownership floor, through @@ -1071,48 +1042,6 @@ export interface ISecurityService { id: string, ): Promise<{ suggestion: AudienceBindingSuggestion }>; - /** - * [ADR-0094] Discard the stale environment-wide `sys_metadata` overlay that - * shadows a PACKAGE-DECLARED permission set, then re-project the - * `sys_permission_set` row from the declared artifact before resolving. This - * is the audited operator action behind the set's "Discard Overlay" Setup - * action (`POST …/security/permission-sets/:id/discard-overlay`). `id` is the - * `sys_permission_set` row id, looked up in the caller's organization. - * - * One set at a time, and never a provenance change: it removes the overlay - * and lets the row converge to its artifact. It does not touch `managed_by` - * or `package_id`. - * - * **Refuses by throwing.** Each refusal carries a registered `code` and an - * HTTP status, which the REST route serves as they are: - * - `PERMISSION_DENIED` (403): the caller is not an authenticated tenant-level - * administrator (a system context bypasses this check), OR no installed - * package currently declares the set. That second refusal protects an - * environment-authored set: discarding its overlay would destroy that work - * with no trace and no way back. - * - `NOT_FOUND` (404): no `sys_permission_set` row has this id. - * - `INVALID_STATE` (409): the set has no active environment-wide overlay to - * discard. - * `NOT_FOUND` and `INVALID_STATE` are listed for this emitter under - * `ERROR_CODE_LEDGER['@objectstack/plugin-security']`. Any other throw is an - * unexpected fault (a failed overlay delete is rethrown as it is). - * - * A refused re-projection write is NOT a refusal: the overlay is already gone, - * so the call resolves. See - * {@link PermissionSetOverlayDiscardResult.healedObjectGrantCount}. - * - * **OPTIONAL, and absence is a defined state, not a bug.** A security service - * without this action omits it. Callers feature-detect - * (`typeof svc.discardPermissionSetOverlay === 'function'`), and the REST route - * answers `501 NOT_IMPLEMENTED` when the method is absent. Declaring it - * optional makes that degradation a property of the type: the unguarded call - * does not compile. - */ - discardPermissionSetOverlay?( - callerContext: SecurityContext, - id: string, - ): Promise; - /** * Contribute alternate matches to the platform's row-level write ownership * floor. The floor is the `created_by == current_user.id` update and delete diff --git a/packages/spec/src/migrations/entries/semantic/18.security-permission-set-overlay-discard-retired.ts b/packages/spec/src/migrations/entries/semantic/18.security-permission-set-overlay-discard-retired.ts new file mode 100644 index 00000000000..4ce0918b1f1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.security-permission-set-overlay-discard-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The ADR-0131 cutover retires the operator action that discarded an +// environment overlay of a package-declared permission set: no caller-reachable +// door creates such an overlay any more, and a database that still holds one is +// refused at cold boot (`security-catalog-environment-overlay-refused`), so the +// action had nothing it could reach on a running server. Registered because it +// removes an HTTP route, a Setup action and an optional contract member that an +// integration may call, and the remedy for the rows it used to clear is an +// offline step a human runs. +export const entry: SemanticMigration = { + id: 'security-permission-set-overlay-discard-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'POST /api/v1/security/permission-sets/:id/discard-overlay, the Discard Overlay action on ' + + 'sys_permission_set, and contracts.ISecurityService.discardPermissionSetOverlay with its ' + + 'PermissionSetOverlayDiscardResult type', + replacement: + 'nothing to call at runtime. To clear an environment overlay of a package-declared permission ' + + 'set, run `os migrate security-catalog-overlays` with the flags and environment the deployment ' + + 'boots with: it lists every such row with no server running, and `--apply` deletes them (a ' + + 'history tombstone per row). To keep a customization of a packaged set, clone the set under a ' + + 'new name (the Clone action) and assign the clone', + reason: + 'The action deleted the environment-wide sys_metadata row shadowing a package-declared ' + + 'permission set and re-projected its sys_permission_set row. Neither half has anything left ' + + 'to do. No door creates such an overlay: the metadata door seals a packaged permission set ' + + 'on every topology (ADR-0131 D6, whatever OS_METADATA_WRITABLE says), the data door refuses ' + + 'a save over one (the packaged lock), the data API cannot write sys_metadata, and a package ' + + 'declaring a name the environment holds is refused at install. An overlay stored before ' + + 'those refusals makes the cold boot refuse the deployment with NAMESPACE_CONFLICT (ADR-0048), ' + + 'so a server that could answer the route never holds one. And the resolver reads the ' + + 'security catalog, not sys_permission_set rows, so the re-projected row granted nothing. ' + + 'ADR-0048, ADR-0087, ADR-0131.', + acceptanceCriteria: + 'No client, script or Setup customization calls ' + + '`POST /api/v1/security/permission-sets/:id/discard-overlay` or ' + + '`discardPermissionSetOverlay`, and no code imports `PermissionSetOverlayDiscardResult` ' + + '(tsc names each importer). `os migrate security-catalog-overlays` lists no row on the ' + + 'deployment\'s database and configuration (exit 0).', +}; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b30be9122b5..9aef84edf40 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3321,16 +3321,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "findOne", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index bd41848d76d..5168a170ae8 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -621,9 +621,6 @@ "packages/plugins/plugin-security/src/permission-set-drift.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-security/src/permission-set-overlay-discard.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/permission-set-projection.test.ts": { "unjudged": 1 }, From 3b92ae2a3a6f8b23ffd60c23ecf232bf54c372d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:54:39 +0000 Subject: [PATCH 2/4] chore(spec): regenerate api-surface and export-origins on the merged tree --- packages/spec/api-surface/contracts.json | 1 + packages/spec/export-origins/contracts.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index ff83320a448..95aa28c5ba7 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -163,6 +163,7 @@ "ISmsTransport (interface)", "IStorageService (interface)", "ITeamGraphService (interface)", + "IWebhookService (interface)", "ImportObjectOpts (interface)", "ImportObjectResult (interface)", "InboxListResult (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 18682790ffc..735cc03da8b 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -163,6 +163,7 @@ "ISmsTransport": "src/contracts/sms-service.ts#ISmsTransport (interface)", "IStorageService": "src/contracts/storage-service.ts#IStorageService (interface)", "ITeamGraphService": "src/contracts/sharing-service.ts#ITeamGraphService (interface)", + "IWebhookService": "src/contracts/webhook-service.ts#IWebhookService (interface)", "ImportObjectOpts": "src/contracts/external-datasource-service.ts#ImportObjectOpts (interface)", "ImportObjectResult": "src/contracts/external-datasource-service.ts#ImportObjectResult (interface)", "InboxListResult": "src/contracts/notification-service.ts#InboxListResult (interface)", From 1907a9bb127bb9c2b1d223d33d6c4720a96a01e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:29:01 +0000 Subject: [PATCH 3/4] test(dogfood): the authz censuses count the retired overlay-discard route out The route-ledger row and the rest-server registration site left with the route: the matrix docblock reads 81 rest rows, and the blind-spot census re-measures 82 -> 81 (rest-route-ledger.ts) and 71/19/52 -> 70/19/51 with enforceAuth 58 -> 57 (rest-server.ts), totals 66/71 -> 65/70. Claude-Session: https://claude.ai/code/session_019TtY6pnoZSemcQRzbRUm5e Co-authored-by: Claude --- .../dogfood/test/authz-conformance.matrix.ts | 2 +- .../test/authz-probe-blind-spot.census.ts | 38 +++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 0e669b08f6b..dc1a69dfaae 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -24,7 +24,7 @@ // hand-curated regex table reaching 1 of 17 REST registrars and 4 of 17 // dispatcher domain files. // -// The population comes from `packages/rest/src/rest-route-ledger.ts` (82 rows +// The population comes from `packages/rest/src/rest-route-ledger.ts` (81 rows // / 18 families) and `packages/runtime/src/route-ledger.ts` (85 rows / 22 // 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 diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 10dd2fc9ea8..62220689e0b 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -350,11 +350,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // It carried `family: 'forms'`, a family the two surviving form rows still // carry, so `reachable` moves with `population`, the blind spot stays 0 and // `keys` stays 18 (18 distinct families before and after, re-derived). - population: 82, - reachable: 82, + // [ADR-0131 cutover, stage 7-pre] 82 / 82 / 0 -> 81 / 81 / 0: the + // permission-set overlay-discard row left with its route. It carried + // `family: 'security'`, which the surviving security rows still carry, so + // `reachable` moves with `population` and `keys` stays 18. + population: 81, + reachable: 81, blindSpot: 0, populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)', - controls: { "route: '": 82, "family: '": 82, RestRouteLedgerEntry: 2 }, + controls: { "route: '": 81, "family: '": 81, RestRouteLedgerEntry: 2 }, // [#22430] The `openapi` family is the second classified one, by the // `anonymous-deny-api-description` row; the note's 2 / 16 counts it. note: @@ -363,7 +367,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' + 'row here is already RED in another package, so a new family cannot be silently absent from this file, ' + 'and therefore cannot be silently absent from the authz ratchet either. 18 families; 2 classified by ' + - 'matrix rows (metadata and openapi), 16 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' + + 'matrix rows (metadata and openapi), 16 enumerated in the shrink-only baseline. Re-measured 82 -> 81 when the permission-set ' + + 'overlay-discard row left with its route; it carried `family: security`, which the surviving security rows still carry. ' + + 'Earlier re-measured 83 -> 82 when the anonymous ' + 'public-form lookup-picker row left with its route; it carried `family: forms`, which the surviving form rows ' + 'still carry, so `reachable` moved with `population` and the families stay 18. Earlier re-measured 91 -> 83 (19 -> 18 families) ' + 'when the whole saved-report `reports` family left with its eight routes, all eight carrying the family, so ' + @@ -442,9 +448,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ kinds: ['ROUTE_ENUMERATION', 'TRIPWIRE'], probes: 3, keys: 1, - population: 71, + population: 70, reachable: 19, - blindSpot: 52, + blindSpot: 51, populationRule: 'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' + '`registerPerItemRoute` (the shared forwarder, not a route; its extent is bounded by the declaration\'s own ' + @@ -549,9 +555,16 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // return;` — two call sites, no prose mention. Both routes were already in // the population and stay outside `registerMetadataEndpoints`, so the blind // spot is unchanged: guarding a route is not reaching it with a key. + // [ADR-0131 cutover, stage 7-pre] 71 / 19 / 52 -> 70 / 19 / 51, and + // `enforceAuth` 58 -> 57: the permission-set overlay-discard route was + // deleted with its handler, one direct `this.routeManager.register(` site + // (64 -> 63) inside `registerSecurityEndpoints`, NOT inside + // `registerMetadataEndpoints`, so it was a blind spot; its handler opened + // with `enforceAuth`, so that control drops by one. The registrar + // survives, so `private register*Endpoints(` stays 16. controls: { 'private register*Endpoints(': 16, - 'this.routeManager.register(': 64, + 'this.routeManager.register(': 63, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -559,7 +572,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 58, + enforceAuth: 57, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + @@ -763,7 +776,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ /** * Entry points inside the probe table's OWN files that no mintable key can * reach, counting only the route/handler surfaces the ratchet's completeness - * claim is about: rest-server.ts (52 static / 57 runtime), http-dispatcher.ts + * claim is about: rest-server.ts (51 static / 56 runtime), http-dispatcher.ts * (13) and domains/mcp.ts (1). * * hono-plugin.ts's 6 mounts are deliberately EXCLUDED from this total and @@ -775,8 +788,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // [#21180] 67 / 72 -> 66 / 71: the retired anonymous lookup-picker route left // rest-server.ts as one blind-spot call site and one runtime mount (measured: // `RestServer.getRoutes()` against a bare protocol, 73 mounts -> 72). -export const BLIND_SPOT_TOTAL_STATIC = 66; -export const BLIND_SPOT_TOTAL_RUNTIME = 71; +// [ADR-0131 cutover, stage 7-pre] 66 / 71 -> 65 / 70: the retired permission-set +// overlay-discard route left rest-server.ts as one blind-spot call site and +// one runtime mount. +export const BLIND_SPOT_TOTAL_STATIC = 65; +export const BLIND_SPOT_TOTAL_RUNTIME = 70; /** * Re-measure every row above from the same sources the probes read. From 11afbdb847a0e5c9d68f7c75ac72381cce76c0a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:27:40 +0000 Subject: [PATCH 4/4] chore(spec): regenerate api-surface and export-origins on the merged tree Claude-Session: https://claude.ai/code/session_019TtY6pnoZSemcQRzbRUm5e Co-authored-by: Claude --- packages/spec/api-surface/contracts.json | 1 + packages/spec/export-origins/contracts.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 95aa28c5ba7..41ebcdd13a4 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -163,6 +163,7 @@ "ISmsTransport (interface)", "IStorageService (interface)", "ITeamGraphService (interface)", + "ITriggerApiService (interface)", "IWebhookService (interface)", "ImportObjectOpts (interface)", "ImportObjectResult (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 735cc03da8b..b70dc9a63f0 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -163,6 +163,7 @@ "ISmsTransport": "src/contracts/sms-service.ts#ISmsTransport (interface)", "IStorageService": "src/contracts/storage-service.ts#IStorageService (interface)", "ITeamGraphService": "src/contracts/sharing-service.ts#ITeamGraphService (interface)", + "ITriggerApiService": "src/contracts/trigger-api-service.ts#ITriggerApiService (interface)", "IWebhookService": "src/contracts/webhook-service.ts#IWebhookService (interface)", "ImportObjectOpts": "src/contracts/external-datasource-service.ts#ImportObjectOpts (interface)", "ImportObjectResult": "src/contracts/external-datasource-service.ts#ImportObjectResult (interface)",