From 0e9cee23991e901fb911a577c1de54cb047acaa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 00:15:39 +0000 Subject: [PATCH] feat(spec): retire the allowRestore / allowPurge object-permission bits (#12497, ADR-0049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's recommendation B. The restore/purge ObjectQL operations the bits claimed to gate have never existed (#8106 vocabulary pin), so authoring them granted nothing. retiredKey() tombstone route (def reachable from the permission root — the rls.priority posture); the evaluator's pre-mapping rows retired in the same batch, with DESTRUCTIVE_OPERATIONS keeping restore/purge denied fail-closed. The keys return with the M2 lifecycle initiative; #1883 stays open as the anchor. - retiredKey tombstones + former-alias guidance in permission.zod.ts - ADR-0087: 4 retired-key entries under major 18, D2 conversion permission-allow-restore-purge-removed, step-18 chain wiring - liveness ledger entries flipped dead (tombstone route keeps them); README + state-counts regenerated - evaluator rows retired; high-privilege allowPurge read removed - pin tests: spec refusal pins, plugin-security fail-closed pins, audience-anchor predicate pin; store-fault suite probe verb purge→transfer - regenerated authorable-surface/, authorable-defaults/, reference docs; hand-written permission docs updated - changeset: spec minor (launch-window convention), plugin-security patch Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 --- .../permission-restore-purge-retired.md | 96 +++++++++++++++++ .../docs/permissions/permission-metadata.mdx | 15 ++- content/docs/permissions/permission-sets.mdx | 2 +- .../docs/permissions/permissions-matrix.mdx | 6 +- content/docs/protocol/objectql/security.mdx | 9 +- content/docs/references/api/protocol.mdx | 8 +- .../docs/references/security/permission.mdx | 12 +-- .../src/audience-anchors.test.ts | 6 +- ...d-by-parent-detail-write-authority.test.ts | 30 ++++-- .../src/permission-evaluator.ts | 69 +++++++----- .../src/security-plugin.test.ts | 78 +++++++++----- .../plugin-security/src/security-plugin.ts | 10 +- .../src/store-fault-fail-closed.test.ts | 20 ++-- .../dogfood/test/authz-conformance.matrix.ts | 4 +- .../spec/authorable-defaults/security.json | 4 - .../spec/authorable-surface/security.json | 8 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/permission.json | 16 +-- packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/conversions/registry.ts | 94 ++++++++++++++++ ...__EffectiveObjectPermission__allowPurge.ts | 16 +++ ...EffectiveObjectPermission__allowRestore.ts | 16 +++ ....security__ObjectPermission__allowPurge.ts | 30 ++++++ ...ecurity__ObjectPermission__allowRestore.ts | 29 +++++ packages/spec/src/migrations/registry.ts | 101 +++++++++++++++++- packages/spec/src/security/high-privilege.ts | 10 +- packages/spec/src/security/permission.test.ts | 56 ++++++++++ packages/spec/src/security/permission.zod.ts | 77 ++++++++++--- 28 files changed, 693 insertions(+), 135 deletions(-) create mode 100644 .changeset/permission-restore-purge-retired.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowPurge.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowRestore.ts diff --git a/.changeset/permission-restore-purge-retired.md b/.changeset/permission-restore-purge-retired.md new file mode 100644 index 0000000000..179f483e84 --- /dev/null +++ b/.changeset/permission-restore-purge-retired.md @@ -0,0 +1,96 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": patch +--- + +feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). +Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's +recommendation B; **the keys return with the M2 lifecycle initiative** (feature ++ RBAC in one batch) — anchor card #1883 stays open. + +`allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and +`purge` (hard-delete / GDPR erase) ObjectQL operations that have never +existed: no destructive lifecycle verb is in the engine's dispatch vocabulary +(pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). +Authoring the bits granted nothing — and in the `allowPurge: false` direction +the failure was ADR-0049's worst false-compliance shape: an admin believed a +lock on permanent deletion existed when the operation itself did not. The +sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id` +door) and is untouched. + +**What is refused:** authoring either key, with any value — both are +`retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the +`permission` metadata root, so the tombstone route keeps the removal audible: +a tsc `never` on the input type plus a parse-time prescription). The former +`restore` / `purge` bare-verb aliases now answer with the same prescription +instead of a rename onto a tombstone. The tombstone rides the `.extend()` +clone into `EffectiveObjectPermissionSchema`, so the response-side def carries +the same `[RETIRED]` rows. + +**What stays accepted:** every other object-permission bit parses +byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`, +`viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`). + +**Runtime (plugin-security):** the evaluator's pre-mapping rows +(`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in +the same batch — with the bits unwritable, a mapping onto them was a claim +about a surface that rejects authoring. Behaviour is deny-before and +deny-after: a dispatched `restore` / `purge` is refused fail-closed by the +`DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even +`modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers +them only when the M2 batch re-adds the rows). `transfer` keeps its row and +its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy +stored value grants nothing, so flagging it guarded nothing real); the +delete/purge/transfer class message is unchanged. + +The retirement kit: + +- `retiredKey()` tombstones + former-alias `guidance` prescriptions at the + schema (`packages/spec/src/security/permission.zod.ts`) +- ADR-0087 registration: retired-key entries + `security/ObjectPermission:allowRestore` / `:allowPurge` (and the + `security/EffectiveObjectPermission` pair for the cloned rows) and the D2 + conversion `permission-allow-restore-purge-removed` (protocol 18), wired + into the step-18 chain — `os migrate meta --from 17` strips the keys from + every object grant in `permissions[].objects` (pure lossless delete; they + never had an effect to lose) +- liveness ledger: both entries flipped to `dead` with the retiredKey evidence + (entries stay — the tombstone keeps the keys in the walked shape, the + `rls.priority` precedent) +- pin tests (`permission.test.ts` — refusal pins asserting the prescription; + `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant + and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate + no longer reads the retired bit) +- generated baselines/docs follow the schema (`authorable-surface/`, + `authorable-defaults/`, spec-changes, upgrade guide, reference docs) + +## FROM → TO + +```ts +// before — parsed green; nothing ever read the bits, no operation existed +definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { + allowRead: true, allowEdit: true, + allowRestore: true, // claimed: can undelete — nothing enforced it + allowPurge: false, // claimed: GDPR erase locked — no lock existed + }, + }, +}); + +// after — delete the keys; restore/purge dispatches are denied fail-closed +// until the M2 lifecycle batch ships the operations WITH their RBAC bits +definePermissionSet({ + name: 'support_agent', + objects: { + crm_ticket: { allowRead: true, allowEdit: true }, + }, +}); +``` + + diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 33d49dc59d..689e8c5f5e 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -79,8 +79,6 @@ objects: { allowEdit: true, // Can update records allowDelete: false, // Can delete records allowTransfer: false, // Can change record ownership - allowRestore: false, // Can restore deleted records - allowPurge: false, // Can permanently delete (GDPR) viewAllRecords: false, // Bypass sharing rules for read modifyAllRecords: false, // Bypass sharing rules for write }, @@ -94,11 +92,16 @@ objects: { | `allowEdit` | Update records (subject to sharing rules) | | `allowDelete` | Soft-delete records | | `allowTransfer` | Transfer record ownership | -| `allowRestore` | Restore records from trash | -| `allowPurge` | Permanently delete records (GDPR compliance) | | `viewAllRecords` | View all records regardless of sharing rules | | `modifyAllRecords` | Edit all records regardless of sharing rules | +> **Retired:** the former `allowRestore` / `allowPurge` keys were removed +> (#12497, ADR-0049) — the `restore` / `purge` operations they claimed to gate +> do not exist yet, so authoring them granted nothing. Authoring either key is +> now a loud publish-time error carrying this prescription. The keys return +> with the M2 lifecycle initiative (feature + RBAC in one batch, #1883); +> until then a dispatched `restore` / `purge` is denied unconditionally. + ## Field Permissions Control visibility and editability of individual fields: @@ -229,8 +232,6 @@ const salesManagerPermission = { allowEdit: true, allowDelete: true, allowTransfer: true, - allowRestore: true, - allowPurge: false, viewAllRecords: true, modifyAllRecords: false, }, @@ -240,8 +241,6 @@ const salesManagerPermission = { allowEdit: true, allowDelete: true, allowTransfer: true, - allowRestore: true, - allowPurge: false, viewAllRecords: true, modifyAllRecords: true, }, diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 05081b0250..f541025220 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -50,7 +50,7 @@ export const SalesUser = definePermissionSet({ |------------|-------------| | `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | CRUD on records the user can see | | `allowExport` | Bulk data egress — an opt-in grant on top of read, see below | -| `allowTransfer` / `allowRestore` / `allowPurge` | Lifecycle class (RBAC-gated ahead of the M2 operations) | +| `allowTransfer` | Lifecycle class: change record ownership — enforced today via the `owner_id` guard (#3004). The former `allowRestore` / `allowPurge` keys were retired (#12497, ADR-0049 — the operations they claimed to gate do not exist yet) and return with the M2 lifecycle batch (#1883) | | `viewAllRecords` | Read ALL records regardless of ownership (super-user read) | | `modifyAllRecords` | Edit ALL records regardless of ownership (super-user write) | diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index 4b77ab6ba8..5b0291a3c3 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -23,13 +23,11 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a | **Delete** | `allowDelete` | Remove records owned by the user or shared with them | Delete own records | | **Export** | `allowExport` | Take a bulk machine-readable copy of the records the user can read | Export / bulk egress ([details](/docs/permissions/permission-sets#allowexport--the-export-axis)) | | **Transfer** | `allowTransfer` | Change record ownership | Reassign owner | -| **Restore** | `allowRestore` | Undelete from trash | Recover soft-deleted records | -| **Purge** | `allowPurge` | Permanently (hard) delete | GDPR / compliance erase | | **View All** | `viewAllRecords` | View all records regardless of ownership or sharing | Read all records (bypass sharing) | | **Modify All** | `modifyAllRecords` | Edit/delete all records regardless of ownership | Full object access (bypass sharing) | -**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle class `allowTransfer`/`allowRestore`/`allowPurge`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`. +**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle bit `allowTransfer`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`. **The one exception is `allowExport`.** Neither super-user bit confers it: a principal with View/Modify All Data may read every record and still be refused a @@ -52,7 +50,7 @@ and `plugin-security/src/security-plugin.ts` (`computeLayeredRlsFilter`). -**Lifecycle operations are partly pending:** the dedicated `transfer` / `restore` / `purge` ObjectQL operations do not exist yet (roadmap M2); their RBAC gate is already mapped in the permission evaluator, so the moment they ship they are denied unless the matching flag (or `modifyAllRecords`) is granted. One exception: `allowTransfer` is **already enforced today** through the ordinary `insert` / `update` door — `owner_id` is system-managed, so planting a record under another user or reassigning / disowning one is denied unless the caller holds `allowTransfer` (or `modifyAllRecords`, which implies it) (#3004). Authoring `allowRestore` / `allowPurge` today still grants nothing (#1883). +**Lifecycle operations are partly pending:** the dedicated `transfer` / `restore` / `purge` ObjectQL operations do not exist yet (roadmap M2). `allowTransfer` is **already enforced today** through the ordinary `insert` / `update` door — `owner_id` is system-managed, so planting a record under another user or reassigning / disowning one is denied unless the caller holds `allowTransfer` (or `modifyAllRecords`, which implies it) (#3004), and the future `transfer` op is pre-mapped to the same bit. The former `allowRestore` / `allowPurge` flags were **retired** (#12497, ADR-0049 — they claimed to gate operations that do not exist, so authoring them granted nothing); a dispatched `restore` / `purge` is denied unconditionally by the fail-closed destructive-operation backstop, and the flags return with the M2 lifecycle batch (#1883). --- diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index db0f8d3393..0984480418 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -100,11 +100,16 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra | --- | --- | | `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | Standard CRUD | | `allowTransfer` | Change record ownership (assign/reassign/disown `owner_id`) — *enforced now via the insert/update `owner_id` guard (#3004); the dedicated `transfer` op is still M2* | -| `allowRestore` | Restore from trash (undelete) — *operation pending (M2); RBAC gate pre-mapped (#1883)* | -| `allowPurge` | Permanently delete (hard delete / GDPR) — *operation pending (M2); RBAC gate pre-mapped (#1883)* | | `viewAllRecords` | Read every record, bypassing sharing & ownership | | `modifyAllRecords` | Write every record, bypassing sharing & ownership | +> The former `allowRestore` / `allowPurge` flags were **retired** (#12497, +> ADR-0049): the `restore` / `purge` operations they claimed to gate have never +> existed, so authoring them granted nothing — the schema now refuses them with +> a migration prescription. A dispatched `restore` / `purge` is denied +> unconditionally (fail-closed destructive-operation backstop). The flags +> return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883). + > Permission sets are **additive-only**: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in `everyone` baseline (ADR-0090 D5). A `true` anywhere wins; there are no subtraction sets — to withhold, don't grant. ### Permission Check Flow diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ed35372299..8cfcfb8ab2 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -986,8 +986,8 @@ Enable package response | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | | **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | | **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | -| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | | **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | @@ -1337,8 +1337,8 @@ Enable package response | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | | **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | | **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | -| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | | **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index bba81b716d..4d0a2081c0 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -57,8 +57,8 @@ const result = AdminScopeSchema.parse(data); | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | | **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | | **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | -| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | | **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | @@ -105,8 +105,8 @@ const result = AdminScopeSchema.parse(data); | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | | **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | | **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | -| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | | **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | @@ -152,8 +152,8 @@ const result = AdminScopeSchema.parse(data); | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | | **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | | **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | -| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | | **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts index 822d270696..94d41db7b0 100644 --- a/packages/plugins/plugin-security/src/audience-anchors.test.ts +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -57,8 +57,12 @@ describe('describeHighPrivilegeBits (anchor-binding predicate)', () => { expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/); expect(describeHighPrivilegeBits({ objects: { a: { modifyAllRecords: true } } })).toMatch(/View\/Modify All/); expect(describeHighPrivilegeBits({ objects: { a: { allowDelete: true } } })).toMatch(/delete\/purge\/transfer/); - expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toMatch(/delete\/purge\/transfer/); expect(describeHighPrivilegeBits({ objects: { a: { allowTransfer: true } } })).toMatch(/delete\/purge\/transfer/); + // `allowPurge` RETIRED (#12497): the bit is a retiredKey tombstone — no + // parsed set can carry it, and a legacy stored row that still does grants + // nothing (no `purge` op, evaluator row retired), so the predicate stopped + // reading it. The M2 batch restores the bit, its gate row, and this read. + expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toBeNull(); expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/); }); diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts index 9d40b3e341..6f05de3b9d 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts @@ -206,14 +206,20 @@ const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_defa /** * Object grants wide enough that NO case below is decided by the CRUD check. * - * The lifecycle three are here for a measured reason. Step 2.7 and step 2.8 are - * both pre-wired for `transfer` / `restore` / `purge` (#1883), but each maps to - * its own grant (`allowTransfer` / `allowRestore` / `allowPurge`, - * `permission.zod.ts`), so a principal holding only CRUD is refused by the + * `allowTransfer` is here for a measured reason: step 2.7 and step 2.8 are + * pre-wired for `transfer` (#1883/#3004) but it maps to its own grant + * (`permission.zod.ts`), so a principal holding only CRUD is refused by the * OBJECT-level check long before either row gate runs — with - * `insufficient_permission`, not a row verdict. Granting them is what makes §1's + * `insufficient_permission`, not a row verdict. Granting it is what makes §1's * per-operation coverage claim about the row gates rather than about the CRUD * bit that never let the operation through. + * + * `allowRestore` / `allowPurge` left this fixture with #12497 (ADR-0049 — + * bits tombstoned, evaluator mapping rows retired; the keys return with M2, + * #1883): they can no longer be authored, and a `restore`/`purge` dispatch is + * refused fail-closed at the object gate, so no grant could carry those two + * verbs to the row gates this suite measures. Their loop cases went with them + * — see BY_ID_WRITE_OPERATIONS below. */ const CRUD = { allowRead: true, @@ -221,8 +227,6 @@ const CRUD = { allowEdit: true, allowDelete: true, allowTransfer: true, - allowRestore: true, - allowPurge: true, }; /** @@ -549,8 +553,16 @@ async function boot(seed: { shares?: Row[] } = {}) { const MASTER_GATE_SENTENCE = /requires edit access to its master record/; const PRE_IMAGE_GATE_SENTENCE = /row-level security\)$/; -/** Step 2.7's set — the operations whose floor this card removes on a cbp detail. */ -const BY_ID_WRITE_OPERATIONS = ['update', 'delete', 'transfer', 'restore', 'purge'] as const; +/** + * Step 2.7's set — the operations whose floor this card removes on a cbp + * detail — MINUS `restore`/`purge` since #12497: their bits are tombstoned and + * their evaluator mapping rows retired, so those two verbs are refused + * fail-closed at the OBJECT gate (`insufficient_permission`) and can no longer + * reach the row gates this loop measures. Step 2.7 still lists them, as + * dormant defense-in-depth; re-add them here when the M2 batch makes them + * grantable again. + */ +const BY_ID_WRITE_OPERATIONS = ['update', 'delete', 'transfer'] as const; describe('[#8757] §1 PRECONDITION — the master gate runs on every by-id write path the floor comes off', () => { // The ruling's licence condition, stated once per operation in step 2.7's diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index eb1a1ec11e..70e3b5f73e 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -5,11 +5,20 @@ import type { PermissionSet, ObjectPermission, FieldPermissionParsed } from '@ob /** * Operation type mapping to permission checks. * - * `transfer`/`restore`/`purge` are pre-mapped to their RBAC bits (#1883) even - * though the ObjectQL operations do not exist yet (roadmap M2): the moment such - * an operation is dispatched through the security middleware it is gated by the - * corresponding `allow*` bit — deny unless a resolved permission set grants it. - * There is no window where the ops could ship ungated. + * `transfer` is pre-mapped to its RBAC bit (#1883) even though the dedicated + * ObjectQL operation does not exist yet (roadmap M2): the moment it is + * dispatched through the security middleware it is gated by `allowTransfer` — + * deny unless a resolved permission set grants it. (`allowTransfer` is also + * ENFORCED today through the ordinary insert/update `owner_id` door, #3004.) + * + * The former `restore`/`purge` rows RETIRED with their bits (#12497, ADR-0049 + * — maintainer ruling 2026-08-26 accepting #1883's recommendation B): + * `allowRestore`/`allowPurge` are `retiredKey()` tombstones in the spec, so a + * mapping onto them was a claim about a surface that rejects authoring. A + * dispatched `restore`/`purge` is now denied unconditionally by the + * DESTRUCTIVE_OPERATIONS backstop below — there is still no window where the + * ops could ship ungated. The rows return with the M2 lifecycle initiative + * (feature + RBAC in one batch), together with the bits they read. */ const OPERATION_TO_PERMISSION: Record = { find: 'allowRead', @@ -20,36 +29,41 @@ const OPERATION_TO_PERMISSION: Record = { update: 'allowEdit', delete: 'allowDelete', transfer: 'allowTransfer', - restore: 'allowRestore', - purge: 'allowPurge', }; /** * Destructive operation class — operations that must FAIL CLOSED when they are * not mapped to a concrete permission key. See ADR-0049: an unrecognised * destructive operation must be DENIED rather than silently allowed by the - * default-allow fallthrough. `transfer`/`restore`/`purge` are now mapped above - * (#1883), so this set acts as a backstop: it keeps them (and any future - * destructive op prefixed here before its mapping lands) fail-closed if the - * mapping is ever removed. Non-destructive unknown operations retain - * default-allow so custom read-side operations are not broken. + * default-allow fallthrough. Since #12497 this set is the ACTIVE gate for + * `restore`/`purge` (their mapping rows retired with their tombstoned bits — + * denial is unconditional, not even `modifyAllRecords` reaches them until the + * M2 ops ship with re-added rows) and the backstop for `transfer` (mapped + * above; this keeps it fail-closed if the mapping is ever removed). + * Non-destructive unknown operations retain default-allow so custom read-side + * operations are not broken. */ const DESTRUCTIVE_OPERATIONS = new Set(['transfer', 'restore', 'purge']); /** * Permission keys covered by the `modifyAllRecords` super-user WRITE bypass: - * edit/delete plus the destructive lifecycle class, DERIVED from the two - * constants above so a future destructive op added to the map+set is covered - * automatically (hand-listing it inline is how bypass gaps happen — #1883). + * edit/delete plus the MAPPED members of the destructive lifecycle class, + * DERIVED from the two constants above so a future destructive op added to the + * map+set is covered automatically (hand-listing it inline is how bypass gaps + * happen — #1883). Unmapped destructive ops (`restore`/`purge` since #12497) + * contribute nothing here — they are denied before the bypass is consulted. * NOTE this means "Modify All Data" grants (incl. the wildcard on - * organization_admin / admin_full_access defaults) will cover - * transfer/restore/purge the moment the M2 ops ship — Salesforce semantics, - * confirmed in the #1883 disposition; revisit per-op when M2 lands. + * organization_admin / admin_full_access defaults) cover `transfer` (and will + * cover restore/purge again when the M2 batch re-adds their rows — Salesforce + * semantics, confirmed in the #1883 disposition; revisit per-op when M2 lands). */ const MODIFY_ALL_WRITE_KEYS = new Set([ 'allowEdit', 'allowDelete', - ...[...DESTRUCTIVE_OPERATIONS].map((op) => OPERATION_TO_PERMISSION[op]), + ...[...DESTRUCTIVE_OPERATIONS].flatMap((op) => { + const key = OPERATION_TO_PERMISSION[op]; + return key ? [key] : []; + }), ]); /** CRUD operation class an object-level `requiredPermissions` map keys on. */ @@ -86,20 +100,21 @@ export function superuserBypassBitForOperation(operation: string): SuperuserBypa * [ADR-0066 ⑤] Map a raw ObjectQL operation to the CRUD class a per-operation * `requiredPermissions` map is keyed on, DERIVED from `OPERATION_TO_PERMISSION` * so it stays in lockstep with the CRUD permission bits (and any future - * destructive op added there). `transfer`/`restore` fold into `update`, - * `purge` into `delete`. Returns `null` for an operation with no CRUD mapping - * (e.g. a custom read-side op) — such an op is never matched by a per-operation - * map, but the flat `string[]` form still gates it via its `all` bucket. + * destructive op added there). `transfer` folds into `update`. Returns `null` + * for an operation with no CRUD mapping (e.g. a custom read-side op) — such an + * op is never matched by a per-operation map, but the flat `string[]` form + * still gates it via its `all` bucket. (`restore`/`purge` fell out of the map + * with #12497 — they resolve `null` here, and their dispatch is denied at the + * object gate before any per-operation map is consulted; the M2 batch re-adds + * the rows, restore→update / purge→delete, with the bits.) */ export function crudBucketForOperation(operation: string): CrudBucket | null { switch (OPERATION_TO_PERMISSION[operation]) { case 'allowRead': return 'read'; case 'allowCreate': return 'create'; case 'allowEdit': - case 'allowTransfer': - case 'allowRestore': return 'update'; - case 'allowDelete': - case 'allowPurge': return 'delete'; + case 'allowTransfer': return 'update'; + case 'allowDelete': return 'delete'; default: return null; } } diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d29f21de47..ab39130b16 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -859,12 +859,16 @@ describe('SecurityPlugin', () => { expect(harness.findOne).toHaveBeenCalledTimes(1); }); - it('DENIES a purge of a not-owned row via the pre-image check (#1883 — destructive ops inherit row-level gating)', async () => { - // The destructive lifecycle class (transfer/restore/purge) is pre-wired - // into OPERATION_TO_PERMISSION, so it clears the object-level RBAC gate. - // The record-level pre-image RLS check must therefore ALSO cover it — - // otherwise a grant-holder could destroy out-of-scope rows by id. purge - // maps onto the `delete` RLS class. + it('DENIES a purge AT the object gate, before any row is read (#12497 — bits tombstoned, rows retired)', async () => { + // Until #12497 this case pinned the pre-image RLS check on a purge whose + // `allowPurge` grant had cleared the object gate (the #1883 pre-wiring). + // The bits retired with their mapping rows — `allowPurge` on a legacy + // stored set (`as any`: the spec type now spells it `never`) grants + // nothing, and the dispatch is refused fail-closed by the object-level + // gate. The row-level machinery must never even be consulted: no + // pre-image read, no sharing probe. (The pre-image coverage claim for + // by-id destructive writes lives on with `delete` above, and returns for + // purge with the M2 batch.) const purgerSet: PermissionSet = { name: 'purger', label: 'Purger', objects: { '*': { allowRead: true, allowPurge: true } }, @@ -876,7 +880,7 @@ describe('SecurityPlugin', () => { const harness = makeMiddlewareCtx({ permissionSets: [purgerSet], objectFields: ownerFields, - findOneImpl: () => null, // row exists but not owned → filtered out → deny + findOneImpl: () => null, }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); @@ -886,7 +890,7 @@ describe('SecurityPlugin', () => { context: memberCtx, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); - expect(harness.findOne).toHaveBeenCalledTimes(1); + expect(harness.findOne).toHaveBeenCalledTimes(0); }); it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => { @@ -2711,9 +2715,10 @@ describe('PermissionEvaluator', () => { it('denies transfer/restore/purge without the matching RBAC bit (#1883)', () => { const evaluator = new PermissionEvaluator(); - // Full CRUD does NOT imply the destructive lifecycle class: each op is - // gated by its own bit (allowTransfer/allowRestore/allowPurge) and must - // be denied when the bit is absent — never default-allow (ADR-0049). + // Full CRUD does NOT imply the destructive lifecycle class: `transfer` is + // gated by its own bit (`allowTransfer`) and must be denied when the bit + // is absent — never default-allow (ADR-0049). `restore`/`purge` are denied + // unconditionally since #12497 (bits tombstoned, mapping rows retired). const ps = makePermSet('member', { contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, }); @@ -2724,25 +2729,39 @@ describe('PermissionEvaluator', () => { expect(evaluator.checkObjectPermission('purge', 'contact', [])).toBe(false); }); - it('allows transfer/restore/purge via their specific RBAC bits (#1883)', () => { + it('allows transfer via its specific RBAC bit (#1883/#3004)', () => { const evaluator = new PermissionEvaluator(); const transferOnly = makePermSet('t', { contact: { allowTransfer: true } }); - const restoreOnly = makePermSet('r', { contact: { allowRestore: true } }); - const purgeOnly = makePermSet('p', { contact: { allowPurge: true } }); expect(evaluator.checkObjectPermission('transfer', 'contact', [transferOnly])).toBe(true); - expect(evaluator.checkObjectPermission('restore', 'contact', [restoreOnly])).toBe(true); - expect(evaluator.checkObjectPermission('purge', 'contact', [purgeOnly])).toBe(true); - // A bit on one op never leaks to another. + // The bit never leaks to another destructive op. expect(evaluator.checkObjectPermission('purge', 'contact', [transferOnly])).toBe(false); - expect(evaluator.checkObjectPermission('transfer', 'contact', [purgeOnly])).toBe(false); + expect(evaluator.checkObjectPermission('restore', 'contact', [transferOnly])).toBe(false); + }); + + it('restore/purge are denied FAIL-CLOSED even for a legacy stored grant (#12497)', () => { + // `allowRestore`/`allowPurge` retired with their mapping rows (#12497, + // ADR-0049 — the ops never existed; the keys return with M2, #1883). A + // 17-era STORED permission-set row can still carry the bits (`as any` — + // the spec type now spells them `never`, and a fresh parse rejects them), + // and it must grant nothing: denial is unconditional via the + // DESTRUCTIVE_OPERATIONS backstop, before any bit or bypass is consulted. + const evaluator = new PermissionEvaluator(); + const legacy = makePermSet('legacy', { + contact: { allowRestore: true, allowPurge: true }, + } as any); + expect(evaluator.checkObjectPermission('restore', 'contact', [legacy])).toBe(false); + expect(evaluator.checkObjectPermission('purge', 'contact', [legacy])).toBe(false); }); - it('modifyAllRecords super-user bypass covers transfer/restore/purge (#1883)', () => { + it('modifyAllRecords super-user bypass covers transfer; restore/purge stay fail-closed (#1883/#12497)', () => { const evaluator = new PermissionEvaluator(); const admin = makePermSet('admin', { contact: { modifyAllRecords: true } }); expect(evaluator.checkObjectPermission('transfer', 'contact', [admin])).toBe(true); - expect(evaluator.checkObjectPermission('restore', 'contact', [admin])).toBe(true); - expect(evaluator.checkObjectPermission('purge', 'contact', [admin])).toBe(true); + // Unmapped destructive ops are denied before the bypass is consulted — + // "Modify All Data" re-covers restore/purge only when the M2 batch re-adds + // their mapping rows (Salesforce semantics, #1883 disposition). + expect(evaluator.checkObjectPermission('restore', 'contact', [admin])).toBe(false); + expect(evaluator.checkObjectPermission('purge', 'contact', [admin])).toBe(false); }); it('should allow via viewAllRecords', () => { @@ -2941,15 +2960,20 @@ describe('crudBucketForOperation (ADR-0066 ⑤)', () => { it('maps insert to `create`', () => { expect(crudBucketForOperation('insert')).toBe('create'); }); - it('folds update/transfer/restore into `update`', () => { - for (const op of ['update', 'transfer', 'restore']) { + it('folds update/transfer into `update`', () => { + for (const op of ['update', 'transfer']) { expect(crudBucketForOperation(op)).toBe('update'); } }); - it('folds delete/purge into `delete`', () => { - for (const op of ['delete', 'purge']) { - expect(crudBucketForOperation(op)).toBe('delete'); - } + it('folds delete into `delete`', () => { + expect(crudBucketForOperation('delete')).toBe('delete'); + }); + it('restore/purge resolve null since #12497 — out of the map with their retired bits', () => { + // Their dispatch is denied at the object gate before any per-operation map + // is consulted; the M2 batch re-adds the rows (restore→update, + // purge→delete) together with the bits. + expect(crudBucketForOperation('restore')).toBeNull(); + expect(crudBucketForOperation('purge')).toBeNull(); }); it('returns null for an operation with no CRUD mapping', () => { expect(crudBucketForOperation('customReadSideOp')).toBeNull(); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 27a2dcb311..184e4f96e2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2025,9 +2025,13 @@ export class SecurityPlugin implements Plugin { // `checkDelete` for the delete class, so an `edit` share widens update and // still leaves delete denied without this file knowing why. if ( - // update/delete today; transfer/restore/purge are pre-wired (#1883) so - // the M2 ops inherit the pre-image check the moment they dispatch — - // the CRUD bit alone must never be the only row-level defense. + // update/delete today; the lifecycle verbs stay listed so the M2 ops + // inherit the pre-image check the moment they dispatch — the CRUD bit + // alone must never be the only row-level defense. `transfer` clears + // the object gate via its mapped bit (#1883/#3004); `restore`/`purge` + // are denied AT the object gate since #12497 (bits tombstoned, rows + // retired, DESTRUCTIVE_OPERATIONS fail-closed), so this branch is + // dormant defense-in-depth for them until the M2 batch re-adds both. ['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) && permissionSets.length > 0 && !!opCtx.context?.userId && diff --git a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts index 000c4590f2..11d9be88cd 100644 --- a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts +++ b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts @@ -233,17 +233,23 @@ describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under ], }); // `update` / `delete` defer to the ADR-0094 write-through, so the gate's own - // row protection is reachable on the lifecycle verbs only. - const purge = (where: Row) => ({ object: 'sys_permission_set', operation: 'purge', options: { where } }); + // row protection is reachable on the lifecycle verbs only. Of those, + // `transfer` is the one a caller can still be GRANTED since #12497 + // (`restore`/`purge` lost their bits and their evaluator rows, so the + // object-level gate downstream refuses them unconditionally and an + // "admitted" leg could never resolve) — the two-doors boundary this suite + // measures is verb-agnostic, so the probe verb moved from `purge` to + // `transfer` with nothing else changing. + const lifecycleWrite = (where: Row) => ({ object: 'sys_permission_set', operation: 'transfer', options: { where } }); // See TENANT_ADMIN_SET: this table's writes pass a second, later gate. const bootPkg = (o: BootOptions = {}) => boot({ sets: [TENANT_ADMIN_SET], ...o }); it('STEADY STATE: a package-managed row is refused, an admin-authored row is admitted', async () => { const h = await bootPkg({ rows: rows() }); - const denied = await refusalOf(h.write(purge({ id: 'ps_pkg' }))); + const denied = await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' }))); expect(denied.code).toBe('PERMISSION_DENIED'); expect(denied.message).toContain('package-managed permission set'); - await expect(h.write(purge({ id: 'ps_env' }))).resolves.toBe('admitted'); + await expect(h.write(lifecycleWrite({ id: 'ps_env' }))).resolves.toBe('admitted'); }); it('FAULT (by id): the outage propagates — it does NOT read as "no package row here"', async () => { @@ -252,7 +258,7 @@ describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under // package-managed, and the purge went through — against the very row the // steady-state case above proves is protected. const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' }); - expectPropagatedOutage(await refusalOf(h.write(purge({ id: 'ps_pkg' })))); + expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' })))); }); it('FAULT (bulk filter): the same answer — one gate cannot answer two ways for one outage', async () => { @@ -260,14 +266,14 @@ describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under // the same question of the same store with `.findOne`, and used to swallow // the same fault with `.catch(() => null)`. const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' }); - expectPropagatedOutage(await refusalOf(h.write(purge({ name: 'Package Set' })))); + expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ name: 'Package Set' })))); }); it('STEADY STATE (bulk filter): a filter that hits no package row is still admitted', async () => { // Fail-closed must not become "refuse everything": the bulk branch exists // precisely so a bulk edit that touches only env-authored rows succeeds. const h = await bootPkg({ rows: rows() }); - await expect(h.write(purge({ managed_by: 'admin' }))).resolves.toBe('admitted'); + await expect(h.write(lifecycleWrite({ managed_by: 'admin' }))).resolves.toBe('admitted'); }); }); diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 7e7c5986e4..dbe91fde12 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -325,6 +325,6 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open. [#7976] The `showcase-anonymous-deny.dogfood.test.ts` CITATION WAS DROPPED under mutual attribution — that file drives the platform default and observes 401, which is precisely what the `anonymous-deny` row (same file) already claims; it never authors `requireAuth: false`, never reads the spec tombstone and never boots an auth-less stack, so it cannot prove the distinguishing half of THIS row (that there is no opt-out). The retirement is pinned elsewhere and unit-side: the spec tombstone + the ADR-0087 conversion entry `stack.api.requireAuth` (conversions/registry.ts, which strips a surviving key) and rest/rest-auth-gate.test.ts. Not high-risk, so the row is sound without a dogfood proof; writing a real one (author `api: { requireAuth: false }` → expect the boot/authoring rejection) is the honest upgrade path, not re-citing the posture proof.' }, // ── Removed — by ADR-0049 (roadmap M2) ───────────────────────────────── - { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed', - note: 'ADR-0049 → roadmap M2. #1883: the ops still do not exist in ObjectQL, but the evaluator PRE-MAPS them (OPERATION_TO_PERMISSION transfer/restore/purge → allowTransfer/allowRestore/allowPurge, modifyAllRecords bypass, unmapped destructive ops fail closed) — there is no ungated window when the ops ship. Unit-proven in plugin-security/security-plugin.test.ts.' }, + { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (transfer bit enforced; restore/purge bits retired)', state: 'removed', + note: 'ADR-0049 → roadmap M2. #1883: the ops still do not exist in ObjectQL. `transfer` stays pre-mapped (OPERATION_TO_PERMISSION transfer→allowTransfer, modifyAllRecords bypass) and `allowTransfer` is ENFORCED today through the insert/update owner_id door (#3004). `allowRestore`/`allowPurge` RETIRED 2026-08-26 (#12497, maintainer ruling accepting #1883 rec B): the bits are retiredKey tombstones and their pre-mapping rows retired with them — a dispatched restore/purge is denied unconditionally via the DESTRUCTIVE_OPERATIONS fail-closed backstop (not even modifyAllRecords reaches it), so there is still no ungated window; the keys + rows + ops return in one M2 batch. Unit-proven in plugin-security/security-plugin.test.ts.' }, ]; diff --git a/packages/spec/authorable-defaults/security.json b/packages/spec/authorable-defaults/security.json index c21bf08692..5ede0be6cd 100644 --- a/packages/spec/authorable-defaults/security.json +++ b/packages/spec/authorable-defaults/security.json @@ -15,9 +15,7 @@ "security/EffectiveObjectPermission:allowCreate = false", "security/EffectiveObjectPermission:allowDelete = false", "security/EffectiveObjectPermission:allowEdit = false", - "security/EffectiveObjectPermission:allowPurge = false", "security/EffectiveObjectPermission:allowRead = false", - "security/EffectiveObjectPermission:allowRestore = false", "security/EffectiveObjectPermission:allowTransfer = false", "security/EffectiveObjectPermission:modifyAllRecords = false", "security/EffectiveObjectPermission:viewAllRecords = false", @@ -28,9 +26,7 @@ "security/ObjectPermission:allowCreate = false", "security/ObjectPermission:allowDelete = false", "security/ObjectPermission:allowEdit = false", - "security/ObjectPermission:allowPurge = false", "security/ObjectPermission:allowRead = false", - "security/ObjectPermission:allowRestore = false", "security/ObjectPermission:allowTransfer = false", "security/ObjectPermission:modifyAllRecords = false", "security/ObjectPermission:viewAllRecords = false", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 407767cb13..4a9e3187f7 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -53,9 +53,9 @@ "security/EffectiveObjectPermission:allowDelete", "security/EffectiveObjectPermission:allowEdit", "security/EffectiveObjectPermission:allowExport", - "security/EffectiveObjectPermission:allowPurge", + "security/EffectiveObjectPermission:allowPurge [RETIRED]", "security/EffectiveObjectPermission:allowRead", - "security/EffectiveObjectPermission:allowRestore", + "security/EffectiveObjectPermission:allowRestore [RETIRED]", "security/EffectiveObjectPermission:allowTransfer", "security/EffectiveObjectPermission:apiOperations", "security/EffectiveObjectPermission:modifyAllRecords", @@ -98,9 +98,9 @@ "security/ObjectPermission:allowDelete", "security/ObjectPermission:allowEdit", "security/ObjectPermission:allowExport", - "security/ObjectPermission:allowPurge", + "security/ObjectPermission:allowPurge [RETIRED]", "security/ObjectPermission:allowRead", - "security/ObjectPermission:allowRestore", + "security/ObjectPermission:allowRestore [RETIRED]", "security/ObjectPermission:allowTransfer", "security/ObjectPermission:modifyAllRecords", "security/ObjectPermission:readScope", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index a30f5886f0..046ec01632 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -791,7 +791,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | flow | dead count = **5 tombstone entries** + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | | action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions), and they are still the whole dead set. **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep | | hook | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | -| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | +| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling) | | position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | | agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | | tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index a4367c6f36..212ea7d419 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -87,16 +87,16 @@ "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `transfer` ObjectQL operation is pending M2, so granting delivers nothing until it ships. Re-verified 2026-07-30: M2 still unshipped (no transfer/restore/purge operations in packages/objectql), the gate mapping stands." }, "allowRestore": { - "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION restore→allowRestore; DESTRUCTIVE_OPERATIONS fail-closed backstop)", - "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `restore` ObjectQL operation is pending M2. Re-verified 2026-07-30: M2 still unshipped." + "status": "dead", + "verifiedAt": "2026-08-26", + "evidence": "packages/spec/src/security/permission.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)", + "note": "REMOVED 2026-08-26 (#12497, ADR-0049 enforce-or-remove — maintainer ruling accepting #1883's recommendation B) — the `restore` ObjectQL operation the bit claimed to gate has never existed (no destructive lifecycle verb in the engine's dispatch vocabulary, #8106 pin), so granting it delivered nothing. The former 'live' verdict cited only the evaluator's pre-mapping row, which retired in the same batch: a mapping onto an unwritable bit is a claim about a surface that rejects authoring. A dispatched `restore` stays denied fail-closed via DESTRUCTIVE_OPERATIONS. Tombstoned at the schema and stripped from sources by the protocol-18 conversion `permission-allow-restore-purge-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the key RETURNS with the M2 lifecycle initiative (#1883, feature + RBAC in one batch) — drop the tombstone (and this entry) only if M2 respells it." }, "allowPurge": { - "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION purge→allowPurge; DESTRUCTIVE_OPERATIONS fail-closed backstop)", - "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `purge` ObjectQL operation is pending M2. Re-verified 2026-07-30: M2 still unshipped." + "status": "dead", + "verifiedAt": "2026-08-26", + "evidence": "packages/spec/src/security/permission.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)", + "note": "REMOVED 2026-08-26 (#12497, ADR-0049 enforce-or-remove — maintainer ruling accepting #1883's recommendation B) — the `purge` ObjectQL operation the bit claimed to gate has never existed (no destructive lifecycle verb in the engine's dispatch vocabulary, #8106 pin), so granting it delivered nothing; worse, an admin who set `allowPurge: false` believed a lock on GDPR hard-deletion existed. The former 'live' verdict cited only the evaluator's pre-mapping row, which retired in the same batch. A dispatched `purge` stays denied fail-closed via DESTRUCTIVE_OPERATIONS. Tombstoned at the schema and stripped from sources by the protocol-18 conversion `permission-allow-restore-purge-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the key RETURNS with the M2 lifecycle initiative (#1883, feature + RBAC in one batch) — drop the tombstone (and this entry) only if M2 respells it." }, "viewAllRecords": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 80ce8b863d..d9b6ad9279 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -32,7 +32,7 @@ for both corollaries. | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 2 | 46 | | `hook` | 18 | 0 | 2 | 0 | 20 | -| `permission` | 38 | 0 | 4 | 0 | 42 | +| `permission` | 36 | 0 | 6 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 12 | | `agent` | 21 | 4 | 1 | 0 | 26 | | `tool` | 13 | 1 | 0 | 0 | 14 | @@ -58,4 +58,4 @@ for both corollaries. | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | | `manifest` | 22 | 0 | 21 | 0 | 43 | -| **total** | **823** | **5** | **76** | **10** | **914** | +| **total** | **821** | **5** | **78** | **10** | **914** | diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index c06ecd3a6e..cd8dc13244 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8093,6 +8093,99 @@ const objectGridDefaultSortRemoved: MetadataConversion = { }, }; +/** + * Object-permission lifecycle bits `allowRestore` / `allowPurge` removed + * (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling + * 2026-08-26 accepting #1883's recommendation B). + * + * The `restore` / `purge` ObjectQL operations the bits claimed to gate have + * never existed: no destructive lifecycle verb is in the engine's dispatch + * vocabulary (pinned by objectql's + * `engine-middleware-operation-vocabulary.test.ts`, #8106). Authoring the bits + * therefore granted nothing — an advertised switch with nothing behind it, on + * the most destructive operations (undelete, GDPR hard-delete). A pure + * lossless delete: a dispatched `restore`/`purge` was denied before (deny + * unless a bit nothing could ever exercise was granted) and stays denied after + * (the evaluator's `DESTRUCTIVE_OPERATIONS` fail-closed backstop — the + * pre-mapping rows retired in the same batch, #12497). The keys RETURN with + * the M2 lifecycle initiative (feature + RBAC in one batch); #1883 stays open. + * + * `allowTransfer` — the third lifecycle bit — is ENFORCED (#3004) and stays. + * + * `retiredFromLoadPath`: ObjectPermissionSchema tombstones both keys + * (`retiredKey`, tsc `never` + the parse-time prescription), the + * `permission-rls-priority-removed` posture one block over. + */ +const permissionAllowRestorePurgeRemoved: MetadataConversion = { + id: 'permission-allow-restore-purge-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'permission.objects..allowRestore / permission.objects..allowPurge', + summary: + "object-permission keys 'allowRestore' and 'allowPurge' removed (#12497, ADR-0049 — the " + + '`restore`/`purge` operations they claimed to gate have never existed, so granting the ' + + 'bits delivered nothing; dispatched destructive lifecycle verbs stay denied fail-closed. ' + + 'The keys return with the M2 lifecycle initiative, #1883)', + apply(stack, emit) { + return mapCollection(stack, 'permissions', (ps, path) => { + const objects = (ps as { objects?: unknown }).objects; + if (!isDict(objects)) return ps; + let touched = false; + const nextObjects: Record = { ...objects }; + for (const [objName, perm] of Object.entries(objects)) { + if (!isDict(perm)) continue; + const stripped = stripKeys(perm, ['allowRestore', 'allowPurge'], emit, `${path}.objects.${objName}`); + if (stripped === perm) continue; + nextObjects[objName] = stripped; + touched = true; + } + if (!touched) return ps; + return { ...ps, objects: nextObjects }; + }); + }, + fixture: { + before: { + permissions: [{ + name: 'support_agent', + label: 'Support Agent', + objects: { + // The measured shape: full CRUD plus the two inert lifecycle bits. + crm_ticket: { + allowRead: true, + allowCreate: true, + allowEdit: true, + allowDelete: true, + allowRestore: true, + allowPurge: false, + }, + // An object WITHOUT the keys rides through untouched — the strip + // dispatches on key presence, and the copy-on-write contract keeps + // the reference. + crm_note: { allowRead: true }, + }, + }], + }, + after: { + permissions: [{ + name: 'support_agent', + label: 'Support Agent', + objects: { + crm_ticket: { + allowRead: true, + allowCreate: true, + allowEdit: true, + allowDelete: true, + }, + crm_note: { allowRead: true }, + }, + }], + }, + // Two notices: both keys on the one object carrying them (presence-based — + // the authored `false` is as dead as the authored `true`). + expectedNotices: 2, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8178,6 +8271,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door // for the audience that exists. 'kernel/PluginHealthCheck:restartBackoff', + // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge` + // (see that entry for the full rationale: ADR-0049 enforce-or-remove, + // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key + // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` + // is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone + // shares the authoring shape's per-property schema instances, so the + // `retiredKey()` tombstone rides into the effective surface and this def's + // walked shape carries the same `[RETIRED]` row. Registered so the aging clock + // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The + // effective surface is server-resolved, never authored, so no D2 conversion + // clause targets it — the authoring-side strip in + // `permission-allow-restore-purge-removed` is the only source rewrite that + // exists to do. + 'security/EffectiveObjectPermission:allowPurge', + // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowRestore` + // (see that entry for the full rationale: ADR-0049 enforce-or-remove, + // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key + // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` + // is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone + // shares the authoring shape's per-property schema instances, so the + // `retiredKey()` tombstone rides into the effective surface and this def's + // walked shape carries the same `[RETIRED]` row. Registered so the aging clock + // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The + // effective surface is server-resolved, never authored, so no D2 conversion + // clause targets it — the authoring-side strip in + // `permission-allow-restore-purge-removed` is the only source rewrite that + // exists to do. + 'security/EffectiveObjectPermission:allowRestore', + // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision- + // inbox batch 5, accepting #1883's recommendation B). `allowPurge` claimed to + // gate a `purge` (hard-delete / GDPR erase) ObjectQL operation that has never + // existed: no destructive lifecycle verb is in the engine's dispatch + // vocabulary (pinned by objectql's + // `engine-middleware-operation-vocabulary.test.ts`, #8106). This was ADR-0049's + // worst false-compliance shape — an admin who set `allowPurge: false` believed + // a lock on permanent deletion existed, when the operation itself did not. + // The permission evaluator's pre-mapping row (`OPERATION_TO_PERMISSION` + // purge→allowPurge) retired in the same batch: with the bit unwritable, a + // mapping onto it was a claim about a surface that rejects authoring. A + // dispatched `purge` stays denied fail-closed via the evaluator's + // `DESTRUCTIVE_OPERATIONS` backstop — there is no ungated window in either + // direction. THE KEY RETURNS with the M2 lifecycle initiative (feature + RBAC + // in one batch, maintainer 2026-08-03); anchor card #1883 stays open. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // ObjectPermissionSchema is `strictObject` but the def is reachable from the + // `permission` metadata root, so the route is the `retiredKey()` tombstone + // (the `rls.priority` posture) — the key stays in the walked shape as + // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the + // prescription. Sources are rewritten by the D2 conversion + // `permission-allow-restore-purge-removed`, which strips the key from every + // object grant in `permissions[].objects`. + 'security/ObjectPermission:allowPurge', + // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision- + // inbox batch 5, accepting #1883's recommendation B). `allowRestore` claimed to + // gate a `restore` (undelete) ObjectQL operation that has never existed: no + // destructive lifecycle verb is in the engine's dispatch vocabulary (pinned by + // objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106), so + // authoring the bit granted nothing — an AI author who declared it believed a + // recycle-bin capability boundary existed, and the failure was silent. The + // permission evaluator's pre-mapping row (`OPERATION_TO_PERMISSION` + // restore→allowRestore) retired in the same batch: with the bit unwritable, a + // mapping onto it was a claim about a surface that rejects authoring. A + // dispatched `restore` stays denied fail-closed via the evaluator's + // `DESTRUCTIVE_OPERATIONS` backstop — there is no ungated window in either + // direction. THE KEY RETURNS with the M2 lifecycle initiative (feature + RBAC + // in one batch, maintainer 2026-08-03); anchor card #1883 stays open. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // ObjectPermissionSchema is `strictObject` but the def is reachable from the + // `permission` metadata root, so the route is the `retiredKey()` tombstone + // (the `rls.priority` posture) — the key stays in the walked shape as + // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the + // prescription. Sources are rewritten by the D2 conversion + // `permission-allow-restore-purge-removed`, which strips the key from every + // object grant in `permissions[].objects`. + 'security/ObjectPermission:allowRestore', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its // renderers/basic/elements.tsx header deferred the element to "owning plugins" diff --git a/packages/spec/src/security/high-privilege.ts b/packages/spec/src/security/high-privilege.ts index 9d29e16b5a..a9ecf2eab1 100644 --- a/packages/spec/src/security/high-privilege.ts +++ b/packages/spec/src/security/high-privilege.ts @@ -62,7 +62,15 @@ export function describeHighPrivilegeBits(def: any): string | null { for (const [objName, rawPerm] of Object.entries(objects)) { const p: any = rawPerm ?? {}; if (p.viewAllRecords || p.modifyAllRecords) return `View/Modify All Data on '${objName}'`; - if (p.allowDelete || p.allowPurge || p.allowTransfer) return `delete/purge/transfer on '${objName}'`; + // The class message keeps the D5 name "delete/purge/transfer", but the + // `allowPurge` READ is gone (#12497): the bit is a retiredKey tombstone — + // no authored or freshly-parsed set can carry it, and a legacy stored row + // that still does grants nothing (no `purge` operation exists, and the + // evaluator's mapping row retired with the bit), so flagging it guarded + // nothing real. When the M2 batch restores the bit and its gate row, + // restore the read here in the same PR — anchor bindings are re-checked + // at boot, so a legacy value regains no privilege silently. + if (p.allowDelete || p.allowTransfer) return `delete/purge/transfer on '${objName}'`; if (p.allowExport) return `bulk export on '${objName}'`; } } diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index dd8dd24c06..14b6b30ddd 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -108,6 +108,62 @@ describe('ObjectPermissionSchema', () => { }); }); +describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => { + // Removed by the 2026-08-26 maintainer ruling accepting #1883's + // recommendation B: the `restore`/`purge` ObjectQL operations the bits + // claimed to gate have never existed (no destructive lifecycle verb in the + // engine's dispatch vocabulary — the #8106 pin), so granting them delivered + // nothing. The tombstone keeps the removal audible instead of silently + // stripping an authored value; the keys return with the M2 lifecycle + // initiative (#1883 stays open). + + it('absent parses clean — no defaults materialize for the retired keys', () => { + const parsed = ObjectPermissionSchema.parse({ allowRead: true }); + expect('allowRestore' in parsed, 'retired key contributes nothing to the parsed output').toBe(false); + expect('allowPurge' in parsed, 'retired key contributes nothing to the parsed output').toBe(false); + }); + + it('authored values reject with the prescription (not a bare strict error)', () => { + for (const key of ['allowRestore', 'allowPurge'] as const) { + // Both directions are dead: the authored `false` claimed a lock that + // never existed just as loudly as the authored `true` claimed a grant. + for (const value of [true, false]) { + const r = ObjectPermissionSchema.safeParse({ [key]: value } as never); + expect(r.success).toBe(false); + const messages = r.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain('#12497'); + expect(messages).toContain('removed in @objectstack/spec 17'); + expect(messages).toContain('Delete the key'); + expect(messages).toContain('M2'); + } + } + }); + + it('the bare verbs carry the prescription too, never a rename onto a tombstone', () => { + // `restore`/`purge` were ALIASES of the retired bits; an alias may only + // prescribe a key the shape accepts (#5013), so both verbs moved to + // `guidance` and answer with the retirement instead of a dead-end rename. + for (const key of ['restore', 'purge'] as const) { + const r = ObjectPermissionSchema.safeParse({ [key]: true } as never); + expect(r.success).toBe(false); + const messages = r.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain('#12497'); + expect(messages).not.toContain(`\`${key}\` → \``); + } + }); + + it('the tombstone rides into the EffectiveObjectPermission clone', () => { + // `.extend()` shares the authoring shape's per-property instances, so the + // response-side def carries the same `[RETIRED]` row in the authorable + // surface — and a DECLARED-never key is refused there even though the + // schema `.strip()`s unknown keys (declared ≠ unknown). No server can emit + // the bit any more (the parsed authoring output omits it), so this refusal + // has no wire-compat cost inside the launch window. + const r = EffectiveObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: false } as never); + expect(r.success).toBe(false); + }); +}); + describe('EffectiveObjectPermissionSchema (#3391 response-side)', () => { it('carries every ObjectPermission field plus optional apiOperations', () => { const parsed = EffectiveObjectPermissionSchema.parse({ diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index fd887b7c8d..abb833d1b2 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -17,6 +17,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; * - Purge (Hard delete / Compliance) */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; /** * [ADR-0057 D1] Object access DEPTH — the Dataverse "access level" axis, @@ -58,8 +59,10 @@ const OBJECT_PERMISSION_KEY_ALIASES: Readonly> = { remove: 'allowDelete', export: 'allowExport', transfer: 'allowTransfer', - restore: 'allowRestore', - purge: 'allowPurge', + // `restore` / `purge` left this table with the #12497 retirement: an alias + // may only prescribe a key the shape ACCEPTS (#5013), and `allowRestore` / + // `allowPurge` are tombstones now. Both verbs moved to `guidance` below so + // an author reaching for them still gets the retirement prescription. canread: 'allowRead', cancreate: 'allowCreate', canedit: 'allowEdit', @@ -80,6 +83,25 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject( 'only on the RESPONSE surface (`/me/permissions`) and is never authored. Grant ' + 'capability with the `allow*` bits here; tighten an object\'s exposure with ' + '`apiMethods` on the object schema.', + // ── Former aliases of the #12497 tombstones. The bare verbs were never + // accepted keys, but until the retirement they were aliased to + // `allowRestore` / `allowPurge`; pointing them at a tombstone would + // send the author into a second rejection (#5013), so they carry the + // prescription directly. + restore: + '`restore` was the alias of `objects..allowRestore`, which was removed in ' + + '@objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed ' + + 'to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete ' + + 'the key — a dispatched `restore` stays denied fail-closed by the permission ' + + 'evaluator\'s destructive-operation backstop, and the bit returns with the M2 ' + + 'lifecycle initiative (#1883) alongside the operation it gates.', + purge: + '`purge` was the alias of `objects..allowPurge`, which was removed in ' + + '@objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed ' + + 'to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete ' + + 'the key — a dispatched `purge` stays denied fail-closed by the permission ' + + 'evaluator\'s destructive-operation backstop, and the bit returns with the M2 ' + + 'lifecycle initiative (#1883) alongside the operation it gates.', }, history: 'Until #4001 these were dropped silently — the permission set still parsed, so the ' + @@ -134,15 +156,8 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject( /** * Lifecycle Operations. * - * RBAC-gated, operations pending (#1883 / roadmap M2). The dedicated - * `transfer`/`restore`/`purge` ObjectQL operations do not exist yet, but the - * permission evaluator PRE-MAPS them to these bits - * (`permission-evaluator.ts` OPERATION_TO_PERMISSION): the moment such an - * operation is dispatched it is denied unless a resolved permission set - * grants the bit (or `modifyAllRecords`). Until the operations ship, - * authoring `restore`/`purge` grants nothing — there is no ungated window - * either way (unmapped destructive ops additionally fail CLOSED via - * DESTRUCTIVE_OPERATIONS, per ADR-0049). + * `allowTransfer` is the one lifecycle bit that stays: ENFORCED (#3004). Its + * former siblings `allowRestore` / `allowPurge` are tombstones below. * * EXCEPTION (#3004): `allowTransfer` is ALREADY ENFORCED today through the * ordinary `insert`/`update` door, not only the future `transfer` op. The @@ -154,8 +169,44 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject( * operation will reuse the same bit. */ allowTransfer: z.boolean().default(false).describe('[RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id)'), - allowRestore: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Restore from trash (Undelete)'), - allowPurge: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR)'), + + /** + * REMOVED — `allowRestore` / `allowPurge` claimed to gate `restore` / + * `purge` ObjectQL operations that have never existed (no destructive + * lifecycle verb is in the engine's dispatch vocabulary — pinned by + * objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106). + * + * ADR-0049 enforce-or-remove, maintainer ruling 2026-08-26 accepting + * #1883's recommendation B (#12497): an AI author who declared + * `allowPurge: false` believed a lock existed; the failure was silent. + * Tombstoned so the removal is audible (tsc `never` + the parse-time + * prescription) instead of a silent strip. The former evaluator pre-mapping + * (`OPERATION_TO_PERMISSION` restore/purge rows) retired in the same batch — + * with the bits unwritable, a mapping onto them was a claim about a surface + * that rejects authoring — and a dispatched `restore`/`purge` stays denied + * fail-closed via the evaluator's `DESTRUCTIVE_OPERATIONS` backstop, so + * there is still no ungated window. THE KEYS RETURN with the M2 lifecycle + * initiative (feature + RBAC in one batch, maintainer 2026-08-03); #1883 + * stays open as the anchor. + */ + allowRestore: retiredKey( + '`objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' + + 'the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so ' + + 'granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied ' + + 'fail-closed by the permission evaluator\'s destructive-operation backstop, and the bit ' + + 'returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), + allowPurge: retiredKey( + '`objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' + + 'the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so ' + + 'granting the bit delivered nothing (a compliance/GDPR erase the author believed was ' + + 'permission-locked was not — the operation itself does not exist). Delete the key — a ' + + 'dispatched `purge` stays denied fail-closed by the permission evaluator\'s ' + + 'destructive-operation backstop, and the bit returns with the M2 lifecycle initiative ' + + '(#1883) alongside the operation it gates. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** * View All Records: Super-user read access.