App reference

ADR-0018: UserService/Create locks to admins once setup is complete

Metadata

  • Status: Accepted
  • Date: 2026-08-05
  • Deciders: Eagraí Clainne Team
  • Related: ADR-0006 (first-time setup flow), ADR-0002 (centralised RPC authorization and the public-endpoint bypass), ADR-0008 (role-based access control)

Context

UserService/Create has been on the public-endpoint bypass list since ADR-0006, so the first-time setup flow can mint the founding admin before anyone is able to authenticate. The PermissionMatrix entry for Create has always said ADMIN-only — but the bypass skips the matrix entirely, so the entry was never enforced. The practical result: any anonymous visitor could self-register a MEMBER account for the whole life of the install, not just during the setup window.

ADR-0006 already treated a post-setup anonymous Create as an accident to degrade away from, not a feature: its point 6 notes that "a lost race does not make Create fail (a second Create quietly succeeds as a member account)" and works around it by re-checking SetupStatus. Eagraí Clainne is a household system — members are added by the family's admin from the Family page, never by strangers signing themselves up. Every legitimate caller after setup (web AddMemberForm, CLI user create, TUI, the seed command after its first login) is already authenticated.

Two adjacent defects surfaced in the same review: the handler dereferenced req.Msg.User without a nil check, so an anonymous request without a user message crashed the connection goroutine with a nil-pointer panic; and the handler validated email format and probed email uniqueness before any authorization decision, so an anonymous caller could test which addresses were registered.

Decision

  1. Create stays on the public-endpoint list — the setup window still needs it — but the handler enforces the matrix intent itself the moment the install has a user:

    • The user count runs first, before any validation.
    • When the count is zero (setup window), the existing X-Initial-Admin-Token gate applies unchanged, and a record-only first user (no email, no password) is refused outright — it could never sign in, and no admin exists to vouch for it.
    • When the count is non-zero, the requester must be an authenticated ADMIN. Anonymous and non-admin callers get PermissionDenied with "setup is complete: only admins can add family members".
  2. PermissionDenied, not Unauthenticated, for anonymous post-setup callers. It is the verdict ADR-0006's Welcome page already handles for the lost-setup-race fallback, and it is the honest one: signing in would not be enough.

  3. The nil-user request is an InvalidArgument, checked before anything else touches the message.

  4. The authorization gate runs before validation, closing the anonymous email-existence probe. (SetupStatus still reports emptiness publicly — that leak was accepted in ADR-0006 and is unchanged.)

  5. The old admin-only gate on requesting initial_roles is subsumed: every non-first creator is an admin by the time roles are decided.

Consequences

Positive

  • The matrix entry and the runtime behaviour finally agree; default-deny means what it says for Create.
  • The Welcome page's race handling gets simpler in effect: a lost race now fails with the verdict the fallback was written for, instead of quietly minting a member account.
  • Anonymous probing (email existence, panic-crash) is closed.

Negative / trade-offs

  • No self-service registration will ever exist without revisiting this ADR. That is the product stance: households invite; strangers don't join.
  • Integration and unit tests that registered second users anonymously had to switch to creating them as the founding admin — the same shape a real install uses.

Alternatives considered

  • Remove Create from the public list, special-case the interceptor on needs_setup. Moves install-state knowledge into the auth interceptor and adds a per-request count on every RPC; the handler already needs the count for the role decision, so the gate is cheapest where it is.
  • A dedicated SystemService/Setup RPC for the first admin, Create fully private. Cleaner in the matrix, but it duplicates the whole create pipeline for one call and breaks the existing Welcome flow and seed command for no behavioural gain.
  • Leave it open, rely on deployment topology (LAN-only). The install is already exposed via HTTPS ingress (push/PWA), and rule 4 says default-deny is structural, not topological.