Skip to content

Bulk save overwrites another user's draft #4648

Open

Description

@WilcoLouwerse

Found during a Strict review of #4580 (development → beta promotion). Present on development; introduced in #4566, #4600.

Impact

A user with update rights on a schema can overwrite someone else's draft through the bulk endpoint, although the single-object path refuses exactly that. Fires whenever the bulk endpoint is used on a draft.

The chain

A draft is hidden from other users on every read path (SQL RBAC predicate) and ObjectService::refuseSomebodyElsesDraft() refuses a single-object save over someone else's draft; keptFrom() (this method) is the only guard and it has exactly one caller. The bulk path does neither. SaveObjects::resolveSafeguardActionForRow() finds the existing row with _rbac: false, so the draft is "found" and the row becomes an update; the per-row check is PermissionHandler::hasPermission(action: update, objectOwner: draftOwner, object: draft), which has no draft rule. Any user who may update in the schema can therefore POST /api/bulk/{register}/{schema}/save with the draft's uuid and replace its property values (_status is carried forward, so it stays a draft and the owner finds it changed). Reproduces the exact hole fixed for the single path in 853d562, on the bulk route (BulkController::save is #[NoAdminRequired]).

lib/Service/Object/DraftStatusPolicy.php:295 — verified against b9e5d8df40.

Reproduction

Verified by reading the code at b9e5d8df40; not executed (no vendor tree, no rig in the review).
Verification: grep -rn "keptFrom" lib --include=*.php → only ObjectService.php:2339 and the definition; grep -n "Draft\|keptFrom" lib/Service/Object/SaveObjects.php → no draft handling besides ObjectEventGate dispatch

Suggested direction

In SaveObjects safeguard step, after the existing entity is resolved and $effectiveRbac === true, reject the row (same recordSafeguardRejection) when (new DraftStatusPolicy())->keptFrom(object: $existingEntity, schema: $rowSchema, userId: $currentUser?->getUID()) is true. Keep the answer indistinguishable from "not found/denied" as on the single path.

Done when: a bulk save by user B carrying the uuid of user A's draft (schema without draftsVisible) returns the row as rejected, the draft's stored data and _updated are unchanged, and a bulk save by A (the owner) still updates it. Unit seam: the permission step of SaveObjects::safeguardRows (new test beside tests/Unit/Service/Object/SaveObjectDraftStatusTest.php).

Review thread

#4580 (comment)

Fix

Proposed in #4655 (not merged yet).

Activity

  1. added
    bugSomething isn't working
    securityA user can read or write what they must not
    on Oct 15, 2026
  2. github-actions commented on Oct 15, 2026

    @github-actions
    Contributor

    Thank you for opening this issue! It has been assigned to @rubenvdlinde for review.

    What happens next:

    1. A maintainer will review this issue and determine the appropriate action
    2. If accepted, it may be converted into an OpenSpec change for structured development
    3. You will be notified of any status changes

    Triage labels:

    • triage:accept-feature — accepted as a new feature
    • triage:accept-bugfix — accepted as a bug fix
    • triage:needs-info — more information needed
    • triage:wontfix — will not be addressed
    • triage:duplicate — duplicate of another issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsecurityA user can read or write what they must nottriageAwaiting triage

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions