Content Versioning: Studio lets a user edit fields they cannot update, producing a version delta that can never be promoted or repaired
- Dominant language
- TypeScript
- Stars
- 37.9k
- Forks
- 4.9k
- Avg merge
- 3d 21h
- Merged PRs (30d)
- 36
Description
### Describe the Bug
When a non-admin edits a **content version**, the Studio makes every **readable** field editable, regardless of the user's **update** field permissions. Field-level access is deferred to promote time by design (see the risk note on #26815). The problem is what happens when that deferred check fails: the offending key is already persisted in `directus_versions.delta`, and there is no supported way to take it back out.
The result is a version that is permanently stuck. `POST /versions/:pk/promote` returns `FORBIDDEN` for the whole delta, and neither of the two write paths onto a version can remove the key. The user cannot publish their work, and cannot fix it themselves.
**Chain of behaviour (v12.2.0):**
1. **App — fields are gated on `read`, never on `update`, while editing a version.**
`app/src/composables/use-permissions/item/lib/get-fields.ts` filters the field list by the `read` permission, then returns early for versions before any `readonly` / `non_editable` flag is applied:
```ts
const readableFields = getPermission(collectionValue, 'read')?.fields;
if (readableFields && !readableFields.includes('*')) {
fields = fields.filter((field) => readableFields.includes(field.field));
}
// Version editing bypasses underlying collection write permissions entirely.
// Field-level access is enforced by the backend at promote time.
if (unref(isVersion)) return fields;
```
`is-action-allowed.ts` does the same for the update action (`if (action === 'update' && unref(isVersion)) return true;`). So a field that is read-yes / update-no renders as a normal editable input inside a version, with no visual signal that it can never be published.
2. **API — the save endpoint accepts it, unchecked.**
`VersionsService.save()` writes the merged delta through a sudo service, so nothing rejects the key at save time:
```ts
const finalVersionDelta = assign({}, existingDelta, revisionDelta);
const sudoService = new ItemsService(this.collection, {
knex: this.knex,
schema: this.schema,
accountability: { ...this.accountability!, admin: true },
});
await sudoService.updateOne(key, { delta: finalVersionDelta });
```
The user gets a successful save and believes the work is stored.
3. **API — promote validates the entire accumulated delta.**
`VersionsService.promote()` passes the whole `rawDelta` to `itemsService.updateOne()` under the user's own accountability, so `processPayload` raises `createFieldsForbiddenError` for any key outside the update permission's `fields` list. One non-updatable key fails the publish of every other change in the version.
4. **No repair path exists.** This is the part that turns an error into a dead end:
- `POST /versions/:pk/save` **merges only** (`assign({}, existingDelta, revisionDelta)`). Sending `null`, `undefined`, or omitting the key cannot delete it.
- `PATCH /versions/:pk` cannot touch the delta at all. `VersionsService.updateMany()` validates the payload against a Joi schema of `key`, `name`, `item`, so any request containing `delta` fails with `InvalidPayloadError`.
- That leaves only two admin-side escapes: a field-subset promote (`POST /versions/:pk/promote` with a `fields` allowlist), or deleting the version and losing the draft. Neither is available to the editor who owns the draft, and the first is not exposed anywhere in the Studio.
### To Reproduce
1. Enable Content Versioning on a collection, for example `articles` with fields `title` and `internal_note`.
2. Create a policy with:
- `read` on `articles`, fields: `title`, `internal_note`
- `update` on `articles`, fields: `title` **only**
- full create/read/update on `directus_versions`
3. Assign it to a non-admin user, and create an item as an admin.
4. As the non-admin, open the item, create a draft version, and edit **both** `title` and `internal_note`. Note that `internal_note` renders as a normal editable field, with no read-only indicator.
5. Save the version. It succeeds (HTTP 200), and `directus_versions.delta` now holds both keys.
6. Publish the version → `FORBIDDEN`. The legitimate `title` change cannot be published either.
7. Try to recover as that user:
- re-save the version with only `{"title": "..."}` → still 200, but the delta keeps `internal_note`, and publish still fails
- `PATCH /versions/:pk` with `{"delta": {"title": "..."}}` → `InvalidPayloadError` ("delta is not allowed")
The version is unrecoverable without an administrator.
### Expected Behaviour
Any one of these would close the trap; the first two seem the most useful:
1. **Gate editability on `update` inside versions too.** Apply the same `readonly` / `non_editable` treatment in `getFields()` when `isVersion` is true, so a non-updatable field is visibly locked and never reaches a delta. This is a partial revert of the deferral introduced in #26815, which was aimed at *conditional* (item-level) update rules, not field-level ones. Item-level conditions can still be deferred to promote; field-level lists do not depend on the item's state and can be resolved up front.
2. **Reject non-updatable fields at save time**, so the delta cannot accumulate a key that can never be promoted. Failing fast at save is far better than failing at publish.
3. **Give the delta a repair path** — allow removing keys from a version delta, either by honouring an explicit delete/unset in `POST /versions/:pk/save` or by permitting `delta` in `PATCH /versions/:pk` for users with update permission on `directus_versions`.
4. At minimum, surface the field-subset promote in the Studio and name the blocking fields in the error, so an editor can publish the rest of their work.
### Impact
We hit this on a production instance with ~590 event editors sharing one policy. A field the editors can read but not update was captured in a draft delta, and the editor was locked out of publishing the whole draft until an administrator promoted a hand-picked field subset and deleted the version. Any editor whose draft touches a read-only field reproduces it, and there is nothing the editor can do about it.
Granting update on the specific field only closes that one instance. On the policy in question, 18 further fields remain readable-but-not-updatable, and deliberately so: `status`, `start_date`, `finish_date`, `name`, `slug`, `id`, `country_id`, `categories`. Those are exactly the fields an editor must be able to see and must not be able to change, so the trap cannot be designed away at the policy level. Any read/update asymmetry, which is the normal reason to use field-level permissions at all, is enough to reproduce it.
### Directus Version
v12.2.0
### Hosting Strategy
Self-Hosted (Docker Image)
### Database
Aurora Mysql 8
Contributor guide
Research direction
Start with app/src/composables/use-permissions/item/lib/get-fields.ts and is-action-allowed.ts, then trace VersionsService.save(), promote(), and updateMany() through the version API. Reproduce the read-versus-update permission case described in the issue and verify the accepted resolution prevents an unrecoverable delta or provides a documented repair path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, authorization, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 50/100