App reference

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

  1. Mandatory field mask replaces full-overwrite. UpdateUserRequest gains a google.protobuf.FieldMask update_mask; the handler applies only masked paths inside the existing svc.Mutate() locked read-modify-write. An absent or empty mask is rejected with InvalidArgument β€” it is not interpreted as "write everything", because the old full-overwrite behaviour is precisely what this decision removes.

  2. Own-profile-or-ADMIN guard on Update(). A non-admin caller may only update the record matching their own UID; anything else is PermissionDenied. This closes the ADR-0009 Phase 2 gap.

  3. 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, birthday
    ADMIN name, email, birthday, deathday, relations

    A self-edit masking relations or deathday is rejected with PermissionDenied. Without this, the mask mechanism would quietly widen self-service beyond the three intended fields.

  4. uid, roles, and password_hash are never writable via Update(), regardless of mask or role. Email changes are validated and checked for uniqueness, since email is the sign-in credential.

  5. Password changes are deferred to a future dedicated ChangePassword endpoint (current-password check + domain.SetPassword). The CLI's --password flag on user update was removed β€” it silently wrote to password_hash, which the server has always ignored β€” and pkg/client fails loudly on password updates until the endpoint exists.

  6. 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 and sessionStorage, 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/client and 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 relations or deathday simply 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.