11# ADR-0092: Identity-table write guard — engine-enforced ` managedBy: 'better-auth' ` , with sys_user profile fields as the first whitelist
22
33- ** Status:** Accepted
4- - ** Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted)
4+ - ** Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · ** 2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment) **
55- ** Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816 )
66- ** Deciders:** ObjectStack Protocol Architects
77- ** Relates to:** [ ADR-0010] ( ./0010-metadata-protection-model.md ) (identity tables managed by better-auth), [ ADR-0049] ( ./0049-no-unenforced-security-properties.md ) (no unenforced security properties), [ ADR-0068] ( ./0068-unified-user-context-and-built-in-identity-roles.md ) (platform-admin gate), [ ADR-0069] ( ./0069-enterprise-authentication-hardening.md ) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
@@ -38,6 +38,9 @@ Decision — one mechanism serves both:
3838 ` allowEdit: false ` ; the standard edit path is therefore ** platform-admin only** .
3939 Self-service profile editing stays on better-auth ` /update-user `
4040 (the existing ` update_my_profile ` action).
41+ ⚠️ ** Amended 2026-09-03** — a member may now edit their OWN row on the generic
42+ data path, bounded by ` member_default ` 's explicit ` sys_user ` entry and the
43+ ` sys_user_self ` RLS carve-out. Read the D5 Amendment below before this bullet.
4144- ** D6** — an ` afterUpdate ` companion hook invalidates the affected user's cached
4245 session snapshots (secondary storage), keeping better-auth session reads coherent
4346 without delegating the write itself to ` internalAdapter.updateUser ` .
@@ -245,6 +248,10 @@ table changes its affordances in this ADR.
245248
246249### D5 — Who can edit whom: unchanged permission topology
247250
251+ * (As amended 2026-09-03 — see the Amendment below for what changed and why. The
252+ original text is kept verbatim, because the Amendment is only readable against
253+ it and because two of the three bullets still hold.)*
254+
248255- ` member_default ` / ` viewer_readonly ` / ` organization_admin ` : ` allowEdit: false ` on
249256 identity tables stays. Nothing about this ADR widens * who* may write.
250257- Platform admins (` admin_full_access ` ) become the only principals whose standard-form
@@ -259,6 +266,68 @@ If org-admin-scoped profile editing is wanted later ("org admin fixes a member's
259266name"), that is a permission-set + RLS decision (` sys_user_org_members ` is currently
260267` select ` -only) layered on top of the same guard — a follow-up, not this ADR.
261268
269+ > ** Amendment (2026-09-03, #14959 — maintainer ruling, decision batch #22 ,
270+ > verbatim 「同意」).** A rank-and-file member ** may** edit their own ` sys_user `
271+ > row on the generic data path. The third bullet above no longer holds: an RLS
272+ > self-row EDIT carve-out for CRUD is exactly what this amendment builds, and
273+ > better-auth's door is no longer strictly better — it cannot carry every
274+ > whitelisted column.
275+ >
276+ > ** What forced it.** The 2026-07-11 text rests on a premise that a later ruling
277+ > retired: that better-auth ` /update-user ` can carry everything Tier 1 holds. It
278+ > could, while Tier 1 was ` {name, image} ` . The 2026-09-03 ruling on #14787
279+ > admitted ` locale ` , and ` locale ` is deliberately ** not** a better-auth
280+ > ` additionalFields ` entry — declaring it there would make ` getSession ` SELECT a
281+ > column an environment that has not run schema-sync does not have (#13881
282+ > measured this; it is the same hazard the ` ai_access ` note in ` auth-manager.ts `
283+ > records). So ` /update-user ` cannot post the column, and with ` member_default `
284+ > denying ` allowEdit ` the generic path could not either. The column shipped
285+ > reachable by platform admins alone — a * user-stated* preference (#14788 ruled
286+ > the stored value outranks ` Accept-Language ` precisely because it is the user's
287+ > own statement) that the user could not state. That is ADR-0049's
288+ > declared-but-not-enforceable shape one step removed, and it is why leaving it
289+ > admin-only was rejected rather than deferred.
290+ >
291+ > ** What the amendment decides.** Self-service edits of the D1 Tier-1 columns
292+ > route through the ** generic data path** , bounded on the two axes that already
293+ > exist and in the shape ` sys_api_key ` has shipped since #8053 :
294+ >
295+ > - ** which rows** — ` member_default ` gains an explicit ` sys_user ` entry
296+ > (` allowRead ` / ` allowEdit ` true, create / delete ** false** ), and its
297+ > ` sys_user_self ` policy (` id == current_user.id ` ) widens from ` select ` to
298+ > ` all ` so it reaches the by-id write pre-image check. ` sys_user_org_members `
299+ > stays ` select ` -only: RLS policies OR-combine, so widening the org-peer
300+ > * visibility* scope would compose ` id == me OR id IN <every user in my org> `
301+ > and hand every member their colleagues' profile rows. The org-admin
302+ > follow-up named at the end of the original D5 text is therefore still open,
303+ > and still a separate decision.
304+ > - ** which columns** — unchanged. D2's guard keeps bounding a user-context
305+ > update to the registered whitelist, so widening * who* does not widen * what* .
306+ > The shape rules on the columns refuse a malformed value identically on every
307+ > path, which is the property that made this the small decision rather than
308+ > the large one.
309+ >
310+ > ` name ` and ` image ` therefore become editable on the generic path too, not only
311+ > through ` /update-user ` . That is the real cost of the amendment and it is
312+ > accepted deliberately: ** D6** already mirrors better-auth's
313+ > ` refreshUserSessions ` for exactly those columns, so the session-cache
314+ > coherence the original bullet bought by routing through ` /update-user ` is
315+ > bought here by the companion hook instead. (` locale ` is correctly * excluded*
316+ > from that mirror — better-auth carries no such field on its user model, so
317+ > there is no stale cached copy to repair and merging one would manufacture an
318+ > incoherence rather than fix one.) Both doors stay open; neither is retired.
319+ >
320+ > ** Rejected in the same ruling** , recorded so they are not re-proposed:
321+ > a dedicated endpoint (` POST /api/v1/me/locale ` , or extending
322+ > ` update_my_profile ` ) writing under system context — the "second stamping
323+ > route" that #14787 's own ruling rejected one level up, and the position where
324+ > a shape check is most easily skipped; and ` locale ` as a better-auth
325+ > ` additionalFields ` entry, refused on #13881 's measurement above.
326+ >
327+ > ** Not amended by this:** D1's tier * table* still lists two Tier-1 members. The
328+ > whitelist constant is the enforced one and holds three; reconciling the table
329+ > is tracked separately (#14951 ).
330+
262331### D6 — Session-cache invalidation companion hook
263332
264333An ` afterUpdate ` hook (same registration site, ` object: 'sys_user' ` ) invalidates the
0 commit comments