Skip to main content

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_users and client_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 of super_admin › administrator › developer › manager › operator › guest. This is the value a token carries as srole — 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), plus ServiceOperatorUserProvider and ServiceClientUserProvider for 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​

SurfacePathsGate
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-deletesystem.users.admin
Self-service avatar/v1/me/avatar/presign-upload, /v1/me/avatar/commit, /v1/me/avatar/sourceuser.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​

KeyGrants
user.readList users
user.updateUpdate user profiles
user.deleteDelete users
user.selfEdit your own profile
user.avatar.selfManage 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 active super_admin + administrator rows 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 with 500 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, every sid pushed 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.