user
Account identity: the per-tier user collections, profile fields, the global system role, OAuth link bookkeeping, and the avatar pipeline. A leaf module with no dependencies — everything else reaches it through iface.UserProvider rather than importing it.
It deliberately does not own passwords, sessions, JWTs, or org memberships. Those belong to auth and tenant.
What it owns
- Two user collections, one per tier —
operator_usersandclient_users. Email uniqueness is scoped per collection, so the same address may legitimately exist as both an operator account and a client account: one human running an internal staff role and an external client account are two different principals. - The system role —
User.Role, one ofsuper_admin›administrator›developer›manager›operator›guest. This is the value a token carries assrole— but only until it changes: the stored row is the authoritative copy, and every guard here reads the row rather than the claim, which can be a whole access-token lifetime out of date. Org-scoped roles are a different thing entirely and live in authz as role bindings. - The avatar pipeline — the reference consumer of the object-storage seam: presign, upload direct to storage, commit. Sources are
uploaded,initials, or a linked OAuth provider's picture. - Three registry providers —
ServiceUserService(canonical, operator-tier), plusServiceOperatorUserProviderandServiceClientUserProviderfor audience-aware consumers.
Each per-tier provider also implements the additive iface.InitialPasswordSetter capability, without changing iface.UserProvider. SetPasswordHashIfUnset persists a first hash only when a live user's stored passwordHash is absent or empty, setting passwordUpdatedAt and updatedAt in the same atomic write. An existing hash returns iface.ErrPasswordAlreadySet and leaves the hash and timestamps unchanged; a missing or soft-deleted user returns iface.ErrUserNotFound. Two concurrent initial writes yield one success and one conflict. The existing UpdatePasswordHash keeps its overwrite behavior for password changes and resets; auth still owns hashing and self-service HTTP policy.
Routes
| Surface | Paths | Gate |
|---|---|---|
| Operator user admin | /v1/users, /v1/users/count, /v1/users/by-email, /v1/users/role/... | system.users.admin |
| Tier-2 client user admin | /v1/admin/client-users and below — list, create, invite, resend, password reset, update, soft-delete | system.users.admin |
| Self-service avatar | /v1/me/avatar/presign-upload, /v1/me/avatar/commit, /v1/me/avatar/source | user.avatar.self, on both tiers |
Hard mutations sit behind a step-up. Updating or deleting a user (operator or client tier) requires an MFA proof fresher than 5 minutes, so a long-lived admin session cannot destructively mutate platform state hours after the last verification. Reads and soft mutations — create, invite, resend, send-password-reset — stay on the plain permission gate.
Permissions
| Key | Grants |
|---|---|
user.read | List users |
user.update | Update user profiles |
user.delete | Delete users |
user.self | Edit your own profile |
user.avatar.self | Manage your own avatar |
:::note A known wrinkle
The admin HTTP routes are currently gated on system.users.admin (contributed by authz), not on the user.* keys above. So user.read / update / delete are granted to managers and operators through the system-role matrix but are not yet the enforcing gate on those endpoints. Only the avatar routes are wired to their own key. Moving the remaining routes to per-route RequirePermission is open work.
:::
Guards worth knowing
These refuse the request rather than trusting the caller, and each returns a distinct error code:
- Last administrator — no delete, deactivation, or role demotion may leave zero live, active platform administrators (
user.last_admin_forbidden). The quorum check counts activesuper_admin+administratorrows and is best-effort under concurrent edits. It is operator-only: a Tier-2 client user is never a platform administrator, so the quorum is not mirrored onto that tier. - Self-delete — an admin cannot delete their own account (
user.self_delete_forbidden). - Role escalation — no one may assign a role above their own tier (
user.role_escalation_forbidden). An administrator can assign administrator but never super_admin. It covers five endpoints: the operator create and update, and the client-user create, invite and patch. The caller's tier is read from the database, not from the token — reading the claim would let a demoted administrator keep minting administrators until their last token expired. A caller with no authenticated identity fails closed with a refusal; a caller whose row is absent or unreadable fails closed with500 user.role_lookup_unavailable, never a fallback to the claim. - Revoking access ends sessions. Deactivating, soft-deleting, or changing a user's system role calls
iface.SessionTerminator— refresh tokens revoked, session docs flipped, everysidpushed into the Redis revocation set. Before this existed a "disabled" account kept working for the full refresh-token lifetime. On the deactivate and delete paths the call is deliberately best-effort and silent: the state change is already persisted, and failing the operator's request would report a deactivation that did in fact happen. - A role change propagates. Changing a system role also retires that user's cached permission verdicts, so the next decision uses the new role rather than one held for the cache's TTL. Neither the cache retirement nor the session termination can refuse the change — refusing a demotion would leave the old role in force indefinitely instead of for a minute — so what did not happen is recorded in the audit row (
cache_invalidated,sessions_terminated) instead. The equivalent client-tier patch needs no cache retirement (a client user's role never enters permission evaluation) and marks that fact explicitly rather than leaving it implied.
Soft delete and the email alias
Deletes are soft — DeletedAt is stamped, the row stays. But the unique email index still matches soft-deleted rows, so a plain soft delete would permanently block that address from re-registering. SoftDeleteAndAliasEmail is the escape: it stamps deletedAt and atomically rewrites the address to an orphan alias, preserving the original on originalEmail for audit. That frees the unique index so the same human can sign up again from scratch.
GDPR
Registers an iface.PIIProducer for the subject user. Export returns the profile projection — never the password hash or PIN, which are server secrets rather than portable personal data. Erasure anonymizes by default: the UUID survives so foreign references stay valid, the email is aliased and the profile blanked, producing the tombstone the compliance retention job later hard-deletes.