ADR-0012: Self-service profile editing via masked, guarded User.Update
Metadata
- Status: Accepted
- Date: 2026-07-24
- Deciders: EagraΓ Clainne Team
- Converted from: RFC 006 (Profile Self-Service), docs/rfcs/006-profile-self-service/ β now removed
- Related: ADR-0007 (formerly RFC 001, Basic Authentication); ADR-0008 (formerly RFC 002, RBAC); ADR-0009 (formerly RFC 003, Service Authorization); ADR-0010 (formerly RFC 004, Roles & Permissions Protobuf)
Context
Members needed to edit their own profile (name, birthday, email) from the web
app's Settings β Account section. UserService.Update() existed and worked,
but three gaps made an edit UI unsafe to ship on top of it:
- No ownership guard. Any authenticated member could rewrite anyone's profile via RPC. ADR-0009 (formerly RFC 003) had documented the "own profile unless ADMIN" guard as a Phase 2 TODO; it was not implemented.
- No partial updates.
Update()wrote all of name/email/birthday/deathday/ relations from the request. A form editing three fields and omitting the rest would silently wipe relations and deathday. - No UI. The Account section was read-only.
Shipping the UI alone would have advertised the authorization hole and introduced a data-loss path, so the backend hardening and the frontend edit sheet were scoped as one change.
Decision
-
Mandatory field mask replaces full-overwrite.
UpdateUserRequestgains agoogle.protobuf.FieldMask update_mask; the handler applies only masked paths inside the existingsvc.Mutate()locked read-modify-write. An absent or empty mask is rejected withInvalidArgumentβ it is not interpreted as "write everything", because the old full-overwrite behaviour is precisely what this decision removes. -
Own-profile-or-ADMIN guard on
Update(). A non-admin caller may only update the record matching their own UID; anything else isPermissionDenied. This closes the ADR-0009 Phase 2 gap. -
Per-role mask allowlist, enforced server-side. The mask restricts what is written; the allowlist restricts who may write it:
Caller Maskable paths Self (member) name,email,birthdayADMIN name,email,birthday,deathday,relationsA self-edit masking
relationsordeathdayis rejected withPermissionDenied. Without this, the mask mechanism would quietly widen self-service beyond the three intended fields. -
uid,roles, andpassword_hashare never writable viaUpdate(), regardless of mask or role. Email changes are validated and checked for uniqueness, since email is the sign-in credential. -
Password changes are deferred to a future dedicated
ChangePasswordendpoint (current-password check +domain.SetPassword). The CLI's--passwordflag onuser updatewas removed β it silently wrote topassword_hash, which the server has always ignored β andpkg/clientfails loudly on password updates until the endpoint exists. -
The web Settings page consumes this. Settings β Account opens an edit sheet (mark/avatar as hero, name/birthday/email fields) that submits
Update()with a mask of the changed fields. Email changes require typing the new address twice (confirm-twice) since a typo means sign-in lockout. On success the returned record refreshes the auth context andsessionStorage, so no stale identity appears anywhere in the UI.
Consequences
Positive
- No cross-field clobber. Partial updates are safe by construction; a client cannot accidentally wipe fields it did not send.
- The authorization hole is closed at the same moment the UI that would have exposed it ships β guard and mask land in one backend change.
- Self-service scope is explicit and enforced, not implied by which fields a client happens to send. Widening it is a deliberate allowlist change.
- The mask carries through
pkg/clientand the CLI, so all clients share the same partial-update semantics.
Negative / trade-offs
- Concurrent edits are last-write-wins on the masked paths. An admin edit
while a member's sheet is open can be overwritten field-by-field.
svc.Mutate()prevents torn records and the mask limits the blast radius; optimistic-concurrency tokens were judged unnecessary at family scale. - No email verification flow. Confirm-twice mitigates typos but does not prove the member controls the new address; a lockout still needs an admin.
- Every
Update()caller must now construct a mask β a small ergonomic cost accepted in exchange for removing the implicit-overwrite footgun.
Alternatives considered
- Ship the UI first, guard later. The initial position; reversed once the field mask forced a proto and handler change anyway. Shipping the edit UI on the unguarded handler would have advertised the hole while adding a data-loss path.
- Empty mask means "write everything". Rejected: it silently preserves the full-overwrite behaviour this change exists to remove.
- Mask mechanism without a per-role allowlist. Rejected: the mask alone
would let a member write
relationsordeathdaysimply by naming them; the allowlist keeps self-service to name/birthday/email. - Email verification flow. Rejected as out of scope; the confirm-twice field plus "this is how you sign in" copy is the accepted mitigation.
- Password change via
Update(). Rejected: it needs a current-password check and dedicated semantics, so it waits for its own endpoint rather than riding a profile edit.