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.