Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/25575-modify-all-allow-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec,lint)!: a permission set can no longer declare `allowDelete: false` beside `modifyAllRecords: true`

`modifyAllRecords` (Modify All Records) is the write bypass for edit, delete and transfer, as Salesforce's Modify All is. The permission evaluator grants delete on the bypass bit and never reads `allowDelete`, so an object-permission entry declaring `allowDelete: false` beside it parsed, was stored, and let its holder delete every record. The runtime does not change. What changes is what an author can write:

- `ObjectPermissionSchema` refuses an explicit `allowDelete: false` in an entry that sets `modifyAllRecords: true`, with one `custom` issue at `objects.<object>.allowDelete` (the wildcard `*` entry included). The message names the route for "edit every record, delete none".
- An entry that sets `modifyAllRecords: true` and omits `allowDelete` now parses to `allowDelete: true` instead of `false`. That is what the evaluator already granted, and it keeps the parse idempotent: the output of `definePermissionSet` still parses when `defineStack`, a build or a save parses it again.
- `os lint` reports the pair as `security-modify-all-delete-withheld` (error) on a raw config that has not been through the schema. `os explain security-modify-all-delete-withheld` prints the route.
- The response-side `EffectiveObjectPermissionSchema` is unchanged and still accepts the pair.

"Edit every record, delete none" is a `criteria` sharing rule with `accessLevel: 'edit'` shared with the position that holds the set, beside `allowEdit: true` and `allowDelete: false` with no `modifyAllRecords`. An edit share opens update and never delete (ADR-0111 D3). The object needs an owner field and an owner-write default (`private` or `public_read`), and the rule's `condition` must be a field predicate every row satisfies, such as `record.id != ''`: a literal `true` lowers to an empty filter, which the sharing service never serves (it logs the rule as not served at boot).

Stored rows and older built artifacts: every earlier parse filled an omitted `allowDelete` with `false`, so the pair also sits where nobody wrote it. The ADR-0087 D2 conversion `permission-modify-all-allow-delete-false-to-true` rewrites it to `true` when a stored row is rehydrated and when a built artifact inside its declared-floor window is loaded. That changes no access decision. It is retired from the load path, so a source that still declares the pair is refused, not rewritten. The D3 entry `permission-modify-all-allow-delete-false-refused` carries the judgment for `os migrate meta`.

Clause-②: no (narrowing) — the object-permission schema refuses a shape it used to accept (`allowDelete: false` beside `modifyAllRecords: true`). Nothing is widened.

Metadata-protocol: narrowed (an object-permission entry may no longer declare `allowDelete: false` beside `modifyAllRecords: true`)

Ruling-ref: 6377890121 (letter A, under 6377842908); retriage 6382861731

<!-- adr-0087: registered permission-modify-all-allow-delete-false-refused, permission-modify-all-allow-delete-false-to-true -->

**BREAKING** for an author who declared the pair.

- **Before.** `{ allowEdit: true, allowDelete: false, modifyAllRecords: true }` parsed, and the holder could delete every record.
- **Now.** The entry is refused at `objects.<object>.allowDelete`.
- **Fix.** If the set should delete every record, delete `allowDelete` from the entry. If it should edit records it does not own but delete none, remove `modifyAllRecords` and add a sharing rule: `{ type: 'criteria', name, object, condition: "record.id != ''", accessLevel: 'edit', sharedWith: { type: 'position', value: '<position>' } }`.
2 changes: 1 addition & 1 deletion content/docs/permissions/permission-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ objects: {
| `allowDelete` | Soft-delete records |
| `allowTransfer` | Transfer record ownership |
| `viewAllRecords` | View all records regardless of sharing rules |
| `modifyAllRecords` | Edit all records regardless of sharing rules |
| `modifyAllRecords` | Edit and delete all records regardless of sharing rules; `allowDelete: false` beside it is refused |

> **Retired:** the former `allowRestore` / `allowPurge` keys were removed
> (#12497, ADR-0049) — the `restore` / `purge` operations they claimed to gate
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const SalesUser = definePermissionSet({
| `allowExport` | Bulk data egress — an opt-in grant on top of read, see below |
| `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) |
| `modifyAllRecords` | Edit and delete ALL records regardless of ownership (super-user write); `allowDelete: false` beside it is refused |

### `allowExport` — the export axis

Expand Down
6 changes: 6 additions & 0 deletions content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
<Callout type="tip">
**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`.

**Modify All includes delete.** An entry that sets `modifyAllRecords: true` and
omits `allowDelete` parses to `allowDelete: true`, and an explicit
`allowDelete: false` beside it is refused at `objects.<object>.allowDelete`,
because the evaluator would never read it. To let a set edit every record but
delete none, use the [edit-share recipe](/docs/permissions/sharing-rules#recipe--edit-every-record-delete-none).

**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
bulk copy of them. Export is an opt-in grant that must be stated explicitly —
Expand Down
49 changes: 49 additions & 0 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,55 @@ grants it had issued rather than re-granting the whole object. If you really
do want everyone to read every record of an object, that is the object's
organization-wide default (`sharingModel`), not a sharing rule.

The gate judges the **lowered filter**, not the intent. A literal
`condition: 'true'` lowers to an empty filter, so the rule is not served (a
declared rule is logged at boot as `match-all-criteria`). A field predicate
that every row happens to satisfy, such as `record.id != ''`, is an ordinary
rule and is served. Use one only where the OWD would grant too much, as in the
recipe below.

### Recipe — edit every record, delete none

`modifyAllRecords` cannot express this: Modify All Records includes delete, and
the schema refuses `allowDelete: false` beside `modifyAllRecords: true`. A
sharing rule can, because an `edit` share opens update and never delete
(ADR-0111 D3). On a `public_read` object whose rows an administrator owns:

```typescript
// The permission set: edit and read, no Modify All, delete withheld.
objects: {
srm_material: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },
},

// The position distributes the set; the rule shares every row with it at `edit`.
definePosition({ name: 'srm_buyer', label: 'Buyer', permissionSets: ['srm_buyer_set'] });

defineSharingRule({
type: 'criteria',
name: 'srm_material_edit_buyers',
object: 'srm_material',
condition: "record.id != ''",
accessLevel: 'edit',
sharedWith: { type: 'position', value: 'srm_buyer' },
});
```

Measured on a booted in-process stack (`@objectstack/verify`): a member who
holds `srm_buyer` updates a row the administrator owns, and their delete of
that row is refused `403 PERMISSION_DENIED`; a row inserted after the rule is
shared the same way; holding the position without a served rule, the same
update is refused at the record. The rule's grants follow the position's
holders and the object's writes, so nobody re-runs it by hand. Field-level
security narrows which fields the edit may touch.

Three conditions, each one a reason the share would enforce nothing:

- The object needs an **owner field** and an owner-write OWD (`private` or
`public_read`). Under `public_read_write` every member with `allowEdit`
already edits every row, so the share adds nothing.
- The condition must be a **field predicate** (see above).
- The permission set still grants `allowEdit`; a share widens rows, never verbs.

> **Retired shapes.** The pre-ADR-0090 `group` recipient was renamed to
> `team`, and the `guest` recipient was removed — anonymous access is served
> by the [public-form grant](/docs/permissions/authorization) and share
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1196,7 +1196,7 @@ This schema accepts one of the following structures:
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (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 alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **allowPurge** | `never` | optional | [REMOVED] `objects.<object>.allowPurge` was removed in @objectstack/spec 17 (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 alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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. |
| **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. Includes delete: an omitted allowDelete parses to true, and an explicit allowDelete: false beside it is refused. |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
| **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org |
| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| …>[]` | optional | Server-resolved effective API operations for this object. Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs), not the authored six-value ApiMethod enum. |
Expand Down Expand Up @@ -1648,7 +1648,7 @@ One changed key path of the pending draft, and the last write that changed it.
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (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 alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **allowPurge** | `never` | optional | [REMOVED] `objects.<object>.allowPurge` was removed in @objectstack/spec 17 (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 alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest 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. |
| **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. Includes delete: an omitted allowDelete parses to true, and an explicit allowDelete: false beside it is refused. |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
| **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org |

Expand Down
Loading
Loading