Skip to content

Row kebab offers Edit/Delete on records the server refuses — the list ANDs only the OBJECT-level grant, the detail header also folds the RECORD-level one #4296

Description

@baozhoutao

Summary

On one screen, for one record and one user, the list row kebab and the record detail header give opposite answers to "may I write this record":

  • Row kebab — shows Edit and Delete, because it ANDs only the object-level verdict (usePermissions().can(obj,'update'|'delete'), rowCrudAffordances.ts layer (d), objectui#4096).
  • Detail header — hides both, because useRecordEditable also folds the record-level verdict (sharing model / writeScope / RLS).

The server agrees with the detail header: the same verbs answer 403 "You do not have access to this record" for that row. So the kebab offers two buttons that cannot succeed.

Repro (real runtime, framework examples/app-showcase, console at the pinned SHA 6314e87)

  1. Boot the showcase (objectstack dev), sign in as admin.
  2. Create a member and grant it a permission set carrying object-level showcase_project: { allowRead, allowEdit, allowDelete: true }. Leave writeScope at its default (own) — every seeded project row is owned by someone else.
  3. Confirm the split server-side, as that member:
    • GET /api/v1/auth/me/permissionsshowcase_project: { allowRead: true, allowEdit: true, allowDelete: true, … } (object level: granted)
    • PATCH /api/v1/data/showcase_project/<foreign row>403 PERMISSION_DENIED, "You do not have access to this record…"
    • DELETE /api/v1/data/showcase_project/<foreign row>403, same record-grained message
  4. In the console as that member, on Legacy Sunset (owner linus@example.com):
    • list → row kebab → Edit and Delete are both offered
    • open the same record's detail page → no Edit button, and the menu contains only Share

Measured cells (UI, same session, same record):

surface Edit Delete
list row kebab shown shown
detail header hidden hidden
server (PATCH / DELETE) 403 403

Why it matters

The object-level grant is not the write verdict on a record — writeScope, the sharing model and RLS narrow it per row. The kebab treating the object grant as final means a user with a legitimately broad object grant sees Edit/Delete on every row they can read, including rows the server will refuse. Clicking ends in a permission error dialog on a button the UI itself put there.

rowCrudAffordances.ts documents the intersection chain (bucket → userActionsapiOperations → per-principal object permission) and notes that layer (c) "fails OPEN for every unprivileged account" without layer (d). The same argument applies one level down: layer (d) fails open for every record the principal does not own.

Suggested direction

Feed the row kebab the same record-grained verdict the detail header uses (the list already holds each row, and /me/permissions already reports the scope dials), or — if a per-row check is too costly in the grid — render the entries disabled with the reason rather than as live actions.

Notes

  • Not a regression of objectui#4096 — that issue closed the object-level hole, which is verified working: with allowDelete:false the kebab Delete, the bulk Delete and the detail Delete are all correctly absent, while an entitled admin sees all three on the same screen.
  • The selection bar could not be measured for this cell on showcase_project: that view declares custom inline bulk defs, so the built-in bulk Delete is absent for every persona there.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpm:queue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions