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
-
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-Tokengate 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
PermissionDeniedwith "setup is complete: only admins can add family members".
-
PermissionDenied, notUnauthenticated, 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. -
The nil-
userrequest is anInvalidArgument, checked before anything else touches the message. -
The authorization gate runs before validation, closing the anonymous email-existence probe. (
SetupStatusstill reports emptiness publicly — that leak was accepted in ADR-0006 and is unchanged.) -
The old admin-only gate on requesting
initial_rolesis 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/SetupRPC 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.