Skip to content

Commit ebb0822

Browse files
claude[bot]claude
andauthored
feat(plugin-security): a rank-and-file member may edit their OWN sys_user row (#15108)
* wip: member self-service sys_user permission-set + RLS opening * wip: update shipped-set pins for the sys_user self-service opening * test: composed route pins for member self-service sys_user edits * chore: changeset for the member self-service sys_user route --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f68e50 commit ebb0822

7 files changed

Lines changed: 687 additions & 42 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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

Comments
 (0)