Skip to content

Commit 1876d5d

Browse files
claude[bot]claude
andauthored
docs(adr): amend ADR-0092 D5 — self-service edits route through the generic data path (#15109)
Maintainer ruling 2026-09-03, decision batch #22 (verbatim 「同意」). Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0faf2bd commit 1876d5d

1 file changed

Lines changed: 70 additions & 1 deletion

File tree

docs/adr/0092-sys-user-profile-field-delegation.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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
259266
name"), 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

264333
An `afterUpdate` hook (same registration site, `object: 'sys_user'`) invalidates the

0 commit comments

Comments
 (0)