Skip to content

Commit 6a70821

Browse files
feat(security): read the recycle bin's grants for restore and purge (#30237 PR 4) (#31438)
domain:server#3 · os-project-manager · session_01FEkQ5pXkgNXVpCn2GZDjyy Body: seat refresh (round 2) · 2026-11-08T12:15Z <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9p73F432cc8kwWQBkFm1pQ)_ Part of #30237 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: follow-up #30847 Spec consent: 6718336940 (`permission.zod.ts` `ObjectPermissionVerbTarget` + `objectPermissionGrants` Q5 cells + describes/docblock; `liveness/permission.json` two rows planned→live) and 6720657486 (the `allowRestore` / `allowPurge` tombstone prescriptions and the `restore` / `purge` guidance strings name the recycle bin's grants). Reviews: 6719122724 (round 1), 6721290796 (delta, head 95d2a17). PR 4 of 5 (hand-over 6690307585): the recycle-bin permission reader. PR 5 (REST doors) follows after this merges. ## 维护者速读 - 改了什么:回收站的两个权限键开始生效:`restore` 读 `allowRestoreOwnDeleted`,`purge`(清除单条、清空、永久删除)读 `manageRecycleBin`;普通成员只能恢复自己删除的记录,恢复别人删的需要 `manageRecycleBin`;被组织墙拒绝的调用者看不到任何回收站条目。共享层与 explain 按写入类别判定生命周期动词:按 id 的永久删除(`purge`)问 `canDelete`,无删除权限的调用者得 403(仅有编辑共享不够),`security/explain` 也按删除门报告 `purge`。 - 为什么改:PR 1–3 已上线回收站,但两个键无人读取,非系统调用者的 restore/purge 一律被拒,回收站对用户不可用;同时关闭 PR 2 评审 should-fix 1(无活动组织的调用者 purge/empty 不受组织范围约束)。键一旦被读取,`purge` 便能到达共享层,故共享门与 explain 必须按删除类判定(评审 6719122724 SF1/SF2)。 - 风险与回滚:放宽(Clause-② widening):持有两键或 `modifyAllRecords` 的人获得 restore/purge;`allowDelete` 不蕴含任何一项(Q5 = A)。无解析器或解析器抛错时,恢复他人条目一律拒绝(fail closed)。共享层对按 id 的 `purge` 是收紧方向(与普通 delete 同门)。回滚即 revert,无数据迁移。 - 先看:`packages/objectql/src/recycle-bin/restore.ts`(`assertCallerMayRestoreEntry`,自有条目检查);`packages/objectql/src/recycle-bin/purge.ts`(`recycleBinWallDenies` 与 `recordedVerdict`);`packages/plugins/plugin-sharing/src/sharing-plugin.ts`(按写入类别选门);`packages/core/src/security/lifecycle-verb-rls-operation.ts`(`rlsOperationForVerb` / `LIFECYCLE_VERB_RLS_OPERATION` 移入 core 并导出,plugin-security 与 plugin-sharing 共用);`packages/spec/src/security/permission.zod.ts`(`objectPermissionGrants` 两个新单元与墓碑文案)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/recycle-bin/restore.test.ts src/recycle-bin/purge.test.ts` 与 `pnpm --filter @objectstack/plugin-sharing exec vitest run src/lifecycle-verb-write-gate.test.ts`,预期全部通过,含「another member's entry … 403」「tenant wall」与「refuses FORBIDDEN when the row is not the caller's to delete」。 - 需要你做的:无,席位落地(契约复核由席位执行)。 ## What changes **The mapping (plugin-security).** `OPERATION_TO_PERMISSION` gains `restore → allowRestoreOwnDeleted` and `purge → manageRecycleBin`, spelled from `RECYCLE_BIN_GRANT_KEYS`. The evaluator answers both through the one fold (`objectPermissionGrants`, #18785); `DESTRUCTIVE_OPERATIONS` stays as the backstop for any future unmapped destructive verb. `MODIFY_ALL_WRITE_KEYS` now derives both keys, and `crudBucketForOperation` folds restore → `update`, purge → `delete`. The system context is unchanged. **The fold (spec, under consent 6718336940).** `objectPermissionGrants` answers the two keys as Q5 = A: each bounded by read; `modifyAllRecords` implies both; `manageRecycleBin` implies `allowRestoreOwnDeleted`; `allowDelete` implies neither. `OBJECT_PERMISSION_VERBS` and `can()` are unchanged. Core's `GRANT_BITS` adds the two keys, so `/me/permissions` reports them. **The write-class map moves to core.** `LIFECYCLE_VERB_RLS_OPERATION` / `rlsOperationForVerb` (transfer / restore → update, purge → delete) are now declared in `packages/core/src/security/lifecycle-verb-rls-operation.ts` and exported from `@objectstack/core`; plugin-security's module re-exports them, so its importers are unchanged and plugin-sharing reads the same map. **Every write-class site reads `rlsOperationForVerb`**: `rls-compiler.ts` `mapOperationToRLS`, `security-plugin-rls-filter.ts`, `security-plugin-addressed-writes.ts`, `security-plugin-cbp-write-gate.ts`, `platform-ownership-policies.ts`. The authz middleware's epoch bump lists `purge` and `restore` beside the other writes. **The sharing write gate judges the write class (review SF1, plugin-sharing).** `buildSharingMiddleware` picks its gate by `rlsOperationForVerb(op)`: a by-id `purge` (the permanent delete) is asked `admitsByIdWrite(object, id, 'delete', …)`, exactly as an ordinary delete is, so a caller without delete authority on the row is refused `403 FORBIDDEN`; an edit share no longer suffices. A bulk `purge` is scoped by `buildWriteFilter(object, ctx, 'delete')`. The recycle bin's own doors (`restore(entryId)`, `purge(entryId)`, `empty(object)`) dispatch with no id and no AST: they address no live row, so the gate passes them on untouched. **`security/explain` reports by the write class (review SF2, plugin-security).** `explain-engine.ts` derives `writeClass = rlsOperationForVerb(engineOp)` and uses it for the record's sharing gate, the authored-deferral verb, the queue guard and the fault sentence: `purge` is explained against `canDeleteRecord`, never `canEditRecord`, so explain no longer reports an edit share as admitting a purge the door refuses. **The engine's own-entry check (`restore.ts`).** After the `restore` dispatch clears the evaluator, `assertCallerMayRestoreEntry` refuses `403 PERMISSION_DENIED` when the entry's `deleted_by` is not the caller's `userId`, unless the caller's effective permissions (the registered `_effectiveObjectPermissionsResolver`) grant `manageRecycleBin` on the entry's object. No resolver, or a resolver that throws → refused. `attributedUserId` is not consulted (attribution only). No new engine member: the `restore()` / `purge()` / `empty()` host literals gain closures over existing state (amendment 6718183894). **The tenant wall on the bin (should-fix 1, `purge.ts`).** `recycleBinWallDenies` answers `deny` for a caller the Layer-0 verdict denies: `restore` → `null`, `purge` → `false`, `empty` → `0`, before anything is read. The `purge` and `empty` executors re-check against `opCtx.tenantLayer0Verdict` and pass `{ recordedVerdict }` to the driver, so the removal is scoped by the verdict the security layer recorded. `restore`'s executor makes the same check. **The retired keys' prose (spec + lint, under consent 6720657486).** The `allowRestore` / `allowPurge` tombstone prescriptions, and the `restore` / `purge` guidance strings, now say the key granted nothing when it was retired and name the recycle bin's grants (`allowRestoreOwnDeleted`, `manageRecycleBin`, ADR-0148) instead of promising the bit back with M2; "Delete the key." stays its own sentence, so the lint's `retiredKeyRemedy` hint is unchanged. `packages/lint/src/rule-explanations.ts` says the same. References regenerated. **verify / core.** `observeWrites` (`packages/verify/src/handle.ts`) now sees `purge` and `restore`. `resolve-user-grants-cache.ts` `WRITE_OPERATIONS` stays insert/update/delete: the bin never takes a platform object, and every object that cache watches is a `sys_` object (comment added). **Docs.** `content/docs/permissions/system-context.mdx` rows 29d/29e own the two new elevation reads (`check:system-context-census`). `permissions/permissions-matrix.mdx` (lifecycle callout) and `protocol/objectql/security.mdx` (the two flag rows and the retired note) now describe the live keys. References and liveness counts regenerated by `check:generated --fix`. ## Tests - `permission-evaluator.test.ts`: named Q5 cells; exhaustive 3-state sweep over the six bits the bin keys read (729 × 2); a present/absent sweep over all ten bits × eight verbs (no bit widens another verb); cross-set read half. - `security-plugin.test.ts`: the old fail-closed pins of the unmapped verbs are rewritten: the object gate answers `restore` / `purge` from their keys and reads no row. - `restore.test.ts`: another member's entry refused with no resolver, with `allowRestoreOwnDeleted` only, with `allowDelete` only, with manage-without-read, with manage on another object, and with a throwing resolver; allowed with `manageRecycleBin` or `modifyAllRecords`; own entry asks no resolver; wall deny → `null`; recorded deny → `null`. - `purge.test.ts`: wall deny → `false` / `0` with no dispatch; recorded deny → `false` / `0`; recorded `organization` verdict scopes the removal (cross-tenant purge through `recordedVerdict`); system exempt. - `plugin-sharing/src/lifecycle-verb-write-gate.test.ts` (new): a by-id `purge` is asked `delete` and refused `FORBIDDEN` when the row is not the caller's to delete, passed when admitted; a bulk `purge` is scoped by the delete filter; the bin's own `restore` / `purge` dispatches pass untouched with nothing asked; the ordinary by-id delete stays on the same gate. - `plugin-security/src/explain-lifecycle-verb-sharing-gate.test.ts` (new): `purge` and `delete` ask `canDeleteRecord`, not `canEditRecord`, and report the row not writable; `update` asks `canEditRecord` (control). - `permission.test.ts` (spec): the fold cells; the retirement pins name ADR-0148 and `manageRecycleBin`, not M2. `validate-retired-permission-residue.test.ts` (lint): the new explanation. `effective-object-permissions.test.ts` (core): the super-user wildcard shape. <details><summary>Ablations (21, each red, each restored to HEAD by scripts/ablation-replace.mjs)</summary> Mutations by `scripts/ablation-replace.mjs` (anchor must hit once; restore proven blob == HEAD). A* run `restore.test.ts` + `purge.test.ts`, B* `permission-evaluator.test.ts` + `security-plugin.test.ts`, C* spec `permission.test.ts`, D1–D2 plugin-sharing `lifecycle-verb-write-gate.test.ts`, D3 plugin-security `explain-lifecycle-verb-sharing-gate.test.ts`. - `A1-not-manager-refusal`: Tests 4 failed | 84 passed; restored=1; e.g. "refuses another member's entry for allowRestoreOwnDeleted only, 403" - `A2-no-resolver-refusal`: Tests 1 failed | 87 passed; restored=1; e.g. "refuses another member's entry, 403, when no permission resolver answers: it fails closed" - `A3b`: Tests 1 failed | 41 passed; restored=1; e.g. "refuses, 403, when the resolver throws: it fails closed" - `A4-own-entry-check-call`: Tests 6 failed | 82 passed; restored=1; e.g. "refuses another member's entry, 403, when no permission resolver answers: it fails closed" - `A5-engine-wall-deny`: Tests 3 failed | 85 passed; restored=1; e.g. "purge answers false for a caller the wall denies, before reading or dispatching anything" - `A6-recorded-deny`: Tests 3 failed | 85 passed; restored=1; e.g. "purge answers false, and removes nothing, when the security layer recorded a deny verdict on the dispatch" - `A7-purge-recorded-scope`: Tests 1 failed | 87 passed; restored=1; e.g. "purge's removal is scoped by the recorded verdict: an organization it does not name keeps its entry" - `A8-empty-recorded-scope`: Tests 1 failed | 87 passed; restored=1; e.g. "empty's removal is scoped by the recorded verdict, not by the caller's own organization" - `A9-restore-executor-wall`: Tests 1 failed | 87 passed; restored=1; e.g. "answers null when the security layer recorded a deny verdict on the dispatch" - `A10-purge-pre-read-wall`: Tests 1 failed | 87 passed; restored=1; e.g. "purge answers false for a caller the wall denies, before reading or dispatching anything" - `A11-empty-pre-wall`: Tests 1 failed | 87 passed; restored=1; e.g. "empty answers 0 for a caller the wall denies, and removes no organization's entries" - `A12-restore-pre-wall`: Tests 1 failed | 87 passed; restored=1; e.g. "answers null for a caller the tenant wall denies (no active organization), and leaves the entry" - `B1-restore-map-row`: Tests 9 failed | 260 passed; restored=1; e.g. "the Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch map" - `B2-purge-map-row`: Tests 9 failed | 260 passed; restored=1; e.g. "the Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch map" - `B3-purge-maps-to-restore-key`: Tests 5 failed | 264 passed; restored=1; e.g. "the Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch map" - `C1-spec-manage-reads-delete`: Tests 1 failed | 121 passed; restored=1; e.g. "[#30237 Q5 = A] folds the recycle-bin keys: bounded by read; manage implies restoring your own; modifyAll impl" - `C2-spec-restore-unbounded`: Tests 1 failed | 121 passed; restored=1; e.g. "[#30237 Q5 = A] folds the recycle-bin keys: bounded by read; manage implies restoring your own; modifyAll impl" - `C3-spec-manage-no-own-restore`: Tests 1 failed | 121 passed; restored=1; e.g. "[#30237 Q5 = A] folds the recycle-bin keys: bounded by read; manage implies restoring your own; modifyAll impl" - `D1-sharing-write-class`: Tests 3 failed | 3 passed; restored=1; e.g. "asks `canDelete` (verb `delete`) of a by-id `purge`, and refuses FORBIDDEN when the row is not the c" - `D2-sharing-bin-door-skip`: Tests 2 failed | 4 passed; restored=1; e.g. "passes the recycle bin's own restore(entryId) dispatch on untouched: no id, no AST, nothing asked" - `D3-explain-write-class`: Tests 1 failed | 2 passed; restored=1; e.g. "purge asks canDeleteRecord, not canEditRecord, and reports the row not writable" </details> ## Acceptance notes - The `purge(entryId)` pre-dispatch entry read cannot carry the recorded verdict: it runs before the dispatch, so it is scoped by the engine's own verdict (which grants no exemption). The removal itself carries `recordedVerdict`. - plugin-security records `opCtx.tenantLayer0Verdict` only on AST operations, so for the AST-less `restore` / `purge` dispatches it is normally absent today and the engine verdict decides. The recorded-verdict path is exercised by a test middleware. - The CBP master gate and the delegated-admin gate do not judge id-less `restore` / `purge` (no predicate path; RBAC `sys_` tables only), as before. - Accepted deviation (review 6719122724 (d)): the authz middleware's bulk-guard site still compares the operation literal rather than reading `rlsOperationForVerb`. It is AST-only, and #7809 keeps `purge` / `restore` AST-free, so no lifecycle verb reaches it. - The sharing gate's pass-through for the bin's id-less, AST-less `restore` / `purge` dispatches keeps `restore` (write class `update`) out of the bulk path; the delta review (6721290796) judged it unreachable by a request a caller can shape. An ordinary `update` / `delete` without id or AST is not passed through. - A super-user wildcard's `/me/permissions` map now shows the two bin keys on `sys_` objects too, where the engine refuses to bin them; the map reports the grant, not the bin's reach. - The remaining "returns with M2" texts outside this PR's surface (conversion and semantic migration entry 18 and its registry row, the ui permission slice, the metadata `plugin.ts` comment, the dead liveness rows, the lint `retiredKeyRemedy` docblock) are tracked in #31508. ## Verification Head `95d2a17f` (origin/main `db502314` merged). 39 files changed, +1088 / −325. - `node scripts/pm/dispatch-gates.mjs --commands`: 124 families; every one run with its exit captured before any pipe; `--ran` reconciliation: `124 derived, 124 run, 0 NOT-MEASURED, 0 UNRUN`; 124/124 exit 0 on the final tree (after `git fetch --unshallow` and a full `pnpm --workspace-concurrency=2 build`). - `check:system-context-census` (two new `isSystem` reads) answered by rows 29d/29e + `--write`. - `pnpm --filter {objectql,plugin-security,plugin-sharing,core,verify,spec,lint} typecheck`: exit 0. eslint over the touched source: exit 0. - Tests: | Suite | Files | Tests | |---|---|---| | plugin-security (full) | 250 | 5873 | | plugin-sharing (full) | 71 | 1470 | | objectql (full) | 570 | 11570 | | core (full) | 143 | 3242 | | lint (full) | 197 | 8363 | | spec security / conversions / migrations | 39 | 1348 | | verify `handle.observe-writes.test.ts` | 1 | 11 | | repo projects | — | core 96, objectql 5, spec 1556 | 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nYzXijPZzrM68FB5J --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4b1f9e commit 6a70821

39 files changed

Lines changed: 1088 additions & 325 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/plugin-sharing': patch
5+
'@objectstack/core': patch
6+
'@objectstack/objectql': patch
7+
'@objectstack/verify': patch
8+
'@objectstack/lint': patch
9+
---
10+
11+
feat(security): the recycle bin's two grants are read: `restore` asks `allowRestoreOwnDeleted` and `purge` asks `manageRecycleBin`, and a member restores only the entries they deleted
12+
13+
Clause-②: yes (widening)
14+
Metadata-protocol: none
15+
Skills-docs: follow-up #30847
16+
17+
Until now every `restore` and `purge` dispatch was refused for every caller but the system, whatever the caller's permission sets granted. The two recycle-bin keys on an object permission are now read (ADR-0148 D5):
18+
19+
- **`allowRestoreOwnDeleted`** answers `restore`. It restores only the entries the caller deleted: restoring an entry someone else deleted is refused `403 PERMISSION_DENIED` unless the caller holds `manageRecycleBin` on the object. When the platform cannot tell what the caller holds, that restore is refused.
20+
- **`manageRecycleBin`** answers `purge`: purging one entry, emptying an object's entries, and the permanent delete (`delete(…, { permanent: true })`). It also restores any entry, not only your own.
21+
- **How the keys fold**: each is bounded by read; `modifyAllRecords` implies both; `manageRecycleBin` implies `allowRestoreOwnDeleted`; `allowDelete` implies neither. `objectPermissionGrants` (`@objectstack/spec/security`) answers both keys this way, and `/me/permissions` reports them.
22+
- **The tenant wall on the bin**: a caller the organization wall denies (for example, signed in with no active organization on a multi-organization deployment) now sees no entry: `restore` answers `null`, `purge` answers `false` and `empty` answers `0`. Before, such a caller's `purge` and `empty` were not scoped to any organization. The removal is scoped by the verdict the security layer recorded on the dispatch when it recorded one.
23+
- **Record sharing on the permanent delete**: the sharing write gate judges `purge` as the delete it is, so the permanent delete of a record by id is refused (`403 FORBIDDEN`) unless the caller may delete that record (ownership, write depth or Modify All Data; an edit share is not enough), and a bulk permanent delete is scoped to the records the caller may delete. `security/explain` reports a purge against the same delete gate.
24+
- **The retired `allowRestore` / `allowPurge` keys stay refused**, but their prescription (and that of the bare `restore` / `purge` keys) no longer says the operations do not exist: it says to delete the key and names the grants that gate the operations now, `allowRestoreOwnDeleted` and `manageRecycleBin`. The residue lint's rule explanation says the same.
25+
- `@objectstack/verify`: a write observer now also sees `purge` and `restore`.
26+
27+
Grant `allowRestoreOwnDeleted` (with read) to the people who should take back what they deleted, and `manageRecycleBin` to the people who administer the bin. Nothing changes for a caller who holds neither.

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ deployment suppresses it. A deployment-wide data superuser needs
7575
</Callout>
7676

7777
<Callout type="warn">
78-
**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).
78+
**Lifecycle operations:** `allowTransfer` is enforced 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); a dedicated `transfer` ObjectQL operation does not exist yet (roadmap M2), and is pre-mapped to the same bit. The recycle bin's `restore` and `purge` operations exist (ADR-0148) and are gated by their own keys (#30237): `restore` asks `allowRestoreOwnDeleted` and restores only the entries the caller deleted, while `purge` — purging an entry, emptying an object's entries, and the permanent delete — asks `manageRecycleBin`, which also restores anyone's entry. Each key is bounded by `allowRead`; `modifyAllRecords` implies both; `allowDelete` implies neither. The former `allowRestore` / `allowPurge` flags stay **retired** (#12497, ADR-0049): they grant nothing, and the schema refuses them.
7979
</Callout>
8080

8181
---

‎content/docs/permissions/system-context.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ bugs — it is the protection or the side effect that silently does not happen.
150150
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `recordReferenceCheckElevation` |
151151
| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a `master_detail` field, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain `lookup` to an object an organization wall scopes asks no read door, system caller or not | `resolveOneHopRelated` |
152152
| 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips, a flow run's excepted) | `masterDetailHeadersCallerReads` |
153+
| 29d | The recycle bin's tenant wall does not apply: `restore`, `purge` and `empty` read and remove **every organization's** entries | objectql | Get: the bin's own doors reach any entry, as the system reaches every wall. Every caller that is not system is held to the Layer-0 verdict: one the wall denies (a signed-in caller with no active organization under a walled posture) sees no entry, so `restore` answers `null`, `purge` `false` and `empty` `0`, and the removal is scoped by the verdict the security layer recorded on the dispatch when it recorded one (#30237) | `recycleBinWallDenies` |
154+
| 29e | A restore of an entry someone else deleted asks **no recycle-bin grant** | objectql | Get: the system restores any entry. Every caller that is not system restores only the entries whose `deleted_by` is its own `userId` unless its effective permissions grant `manageRecycleBin` on the entry's object; otherwise `403 PERMISSION_DENIED`, and the same refusal when no permission resolver is registered or it throws (#30237, ADR-0148 D5) | `assertCallerMayRestoreEntry` |
153155
| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `registerAuthzMiddleware` |
154156
| 30b | **The import doors admit a control opt-out** — `runAutomations: false` and `treatAsHistorical: true` on `POST /data/:object/import` and `/import/jobs` | rest | Get: an import whose rows are written with `skipAutomations` (no metadata-bound hook, no record-change trigger: an app's approvals and record freezes among them) or with `skipStateMachine` and `preserveAudit`. Every other caller, every signed-in user and administrator included, is refused `403 PERMISSION_DENIED` before the body is parsed, so nothing is written and no job is created. Inbound HTTP never carries the flag, so this is an in-process caller's door. Note: the flag still suppresses none of those controls by itself (see below); it only admits the opt-outs that do | `importControlOptOutRefusal` |
155157
| 30c | **The import undo keeps the control skips** — `POST /data/import/jobs/:jobId/undo` | rest | Get: an undo whose deletes and restores are written with `skipAutomations` and `skipStateMachine`, so no metadata-bound hook, record-change trigger or `state_machine` rule refuses a reversal. Every other caller, every signed-in user and administrator included, undoes under those controls: a reversal one of them refuses is reported per row in the undo answer's `failures` and kept in the job's undo log, and the job is stamped reverted only once every row is. A historical job's `preserveAudit` belongs to the job, not the caller, and rides every undo. Inbound HTTP never carries the flag, so this is an in-process caller's door | `importUndoWriteContext` |
@@ -551,6 +553,8 @@ and CI refuses one that differs from what the tree produces.
551553
| `packages/objectql/src/engine.ts#isSystem` | 1 | 64 |
552554
| `packages/objectql/src/engine.ts#update` | 4 | 19, 20 |
553555
| `packages/objectql/src/internal-field-evaluation-guard.ts#rejectInternalFieldEvaluation` | 1 | 18e |
556+
| `packages/objectql/src/recycle-bin/purge.ts#recycleBinWallDenies` | 1 | 29d |
557+
| `packages/objectql/src/recycle-bin/restore.ts#assertCallerMayRestoreEntry` | 1 | 29e |
554558
| `packages/objectql/src/stored-metadata-type-read-gate.ts#storedMetadataTypeReadFilter` | 1 | 18d |
555559
| `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | 1 | 13 |
556560
| `packages/plugins/organizations/src/organizations-plugin.ts#start` | 1 | 62 |

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,17 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
101101
| --- | --- |
102102
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | Standard CRUD |
103103
| `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* |
104+
| `allowRestoreOwnDeleted` | Recycle bin: restore the entries of this object the caller deleted (`restore`). Bounded by read; implied by `manageRecycleBin` and `modifyAllRecords`, not by `allowDelete` |
105+
| `manageRecycleBin` | Recycle bin administration: restore any entry, and `purge` (purging an entry, emptying the object's entries, the permanent delete). Bounded by read; implied by `modifyAllRecords` |
104106
| `viewAllRecords` | Read every record, bypassing sharing & ownership |
105107
| `modifyAllRecords` | Write every record, bypassing sharing & ownership |
106108

107109
> The former `allowRestore` / `allowPurge` flags were **retired** (#12497,
108-
> ADR-0049): the `restore` / `purge` operations they claimed to gate have never
109-
> existed, so authoring them granted nothing — the schema now refuses them with
110-
> a migration prescription. A dispatched `restore` / `purge` is denied
111-
> unconditionally (fail-closed destructive-operation backstop). The flags
112-
> return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883).
110+
> ADR-0049): when they were retired the `restore` / `purge` operations did not
111+
> exist, so authoring them granted nothing — the schema refuses them with a
112+
> migration prescription. The operations now exist with the recycle bin
113+
> (ADR-0148), and their grants are spelled `allowRestoreOwnDeleted` and
114+
> `manageRecycleBin` (#30237).
113115
>
114116
> One exception, by ruling: the boolean literal `false` — the default the
115117
> published 17.x toolchain emitted into every built artifact — parses as inert

0 commit comments

Comments
 (0)