|
| 1 | +--- |
| 2 | +"@objectstack/plugin-security": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(plugin-security): a rank-and-file member may edit their OWN `sys_user` row (#14959) |
| 6 | + |
| 7 | +Maintainer ruling 2026-09-03, decision batch #22, quoted verbatim and |
| 8 | +untranslated as adopted: |
| 9 | + |
| 10 | +> 「同意」 |
| 11 | +
|
| 12 | +The ruling that admitted `locale` to the ADR-0092 D2 column whitelist (#14787 / |
| 13 | +PR #14958) opened **which columns** a permitted actor may touch. It did not open |
| 14 | +**who**, and ADR-0092 D5 kept that with the permission layer, where |
| 15 | +`member_default` still denied `allowEdit` on `sys_user`. The measured |
| 16 | +consequence: a member's `PATCH /api/v1/data/sys_user/<self>` was refused by the |
| 17 | +object gate *before* the column guard was ever consulted, so `sys_user.locale` |
| 18 | +shipped as a user-stated preference only a platform administrator could set — |
| 19 | +with objectui#7501's "my language" form item waiting on a route that did not |
| 20 | +exist, and #14788 having already ruled the stored value outranks |
| 21 | +`Accept-Language` *because it is the user's own choice*. |
| 22 | + |
| 23 | +This opens the route, on the two axes that already existed and in the shape |
| 24 | +`sys_api_key` has shipped since #8053: |
| 25 | + |
| 26 | +- **Which rows** — `member_default` gains an explicit `sys_user` entry |
| 27 | + (`allowRead`/`allowEdit` true, create/delete **false**), and its |
| 28 | + `sys_user_self` RLS carve-out (`id == current_user.id`) widens from `select` |
| 29 | + to `all` so it reaches the by-id write pre-image check. `sys_user_org_members` |
| 30 | + — the org-peer *visibility* policy — deliberately stays `select`-only: |
| 31 | + policies OR-combine, so widening it would have composed |
| 32 | + `id == me OR id IN <every user in my org>` and handed every member their |
| 33 | + colleagues' profile rows. |
| 34 | +- **Which columns** — unchanged. ADR-0092 D2's identity write guard still bounds |
| 35 | + a user-context update to `SYS_USER_PROFILE_EDIT_FIELDS` |
| 36 | + (`name`, `image`, `locale`); `email`, `role`, the ban columns and every system |
| 37 | + stamp stay unwritable on this path. |
| 38 | + |
| 39 | +`allowCreate` / `allowDelete` stay false: accounts are minted and retired |
| 40 | +through better-auth's own endpoints, and this set is bound to the `everyone` |
| 41 | +anchor, which must remain anchor-safe (ADR-0090 D5). |
| 42 | + |
| 43 | +**ADR-0092 D5 is amended** by the same ruling — self-service edits of the |
| 44 | +whitelisted columns route through the generic data path, with the D6 |
| 45 | +`afterUpdate` hook as the session-cache refresh. `name` / `image` therefore |
| 46 | +become editable there too, not only through better-auth `/update-user`. The |
| 47 | +amendment ships as its own PR (`docs/adr/**` is governed and merged by hand). |
| 48 | + |
| 49 | +Rejected in the same ruling, recorded so they are not re-proposed: a dedicated |
| 50 | +endpoint writing under system context (the "second stamping route" #14787's own |
| 51 | +ruling rejected, one level up); leaving the column admin-only (a user-facing |
| 52 | +setting only an administrator can set — ADR-0049's declared-not-reachable shape, |
| 53 | +one step removed); and making `locale` a better-auth `additionalFields` entry |
| 54 | +(#13881 measured that it breaks `getSession` on any environment that has not run |
| 55 | +schema-sync). |
| 56 | + |
| 57 | +The pins are layer-attributed on purpose. Each of the four cases the ruling names |
| 58 | +records *which* of the three layers produced its answer — object gate, row scope, |
| 59 | +or identity guard — because before this change all four were refused by the |
| 60 | +object gate, so "another member's row is refused" and "a non-whitelisted column |
| 61 | +is refused by the guard" were both green while neither mechanism had run. A |
| 62 | +two-leg ablation confirms it: reverting the permission-set entry drops the |
| 63 | +non-whitelisted-column refusal from `identity-guard` to `object-gate`, and |
| 64 | +reverting only the RLS widening drops it to `row-scope`. |
0 commit comments