users
The default auth provider + user-management module: email/password login, OAuth/OIDC sign-in, sessions, registration, invites, password reset, email verification, role assignment, admin UI, and mailer backends. It registers a UsersAuthProvider on app.state.auth.auth_provider, which the auth module's AuthMiddleware delegates to for request.state.user. (It's one of two bundled auth providers — keycloak is the alternative; install exactly one.)
ModuleMeta
| Field | Value |
|---|---|
name | Users |
route_prefix | /api/users |
view_prefix | /users (sign-in, self-service) |
admin_view_prefix | /admin/users (management CRUD) |
depends_on | ["Auth"] |
Auth flow
The module is built on fastapi-users for password hashing, registration, password reset, and verification. On top of that it layers:
- A signed session cookie (
sm_authby default) — the auth module'sAuthMiddlewarecallsUsersAuthProvider.resolve_user, which reads it on every request and populatesrequest.state.user. Bearer tokens (Authorization: Bearer <token>) resolve againstusers_access_token. - OAuth / OIDC sign-in (Google, GitHub, Microsoft/Entra ID, and any generic OIDC provider) — see OAuth providers.
- An invite flow — admins generate an invite link; the recipient sets their password via
POST /api/users/auth/accept-invite. LoginRateLimiter— N failures within a window triggers a cooldown peremail::ipkey.ThroughputLimiter— per-IP rate limit across register / forgot-password / accept-invite / verify-token endpoints.
Auth endpoints
| Method + path | Body | Notes |
|---|---|---|
POST /api/users/auth/login | OAuth2PasswordRequestForm | sets sm_auth cookie + session["user_id"]; rate-limited per email |
POST /api/users/auth/register | UserCreate | gated by users.allow_signup; rate-limited |
POST /api/users/auth/forgot-password | PasswordReset | rate-limited |
POST /api/users/auth/reset-password | {token, password} | |
POST /api/users/auth/request-verify-token | RequestVerifyToken | rate-limited |
POST /api/users/auth/verify | VerifyRequest | |
POST /api/users/auth/accept-invite | AcceptInviteRequest | sets password + signs the user in |
POST /api/users/auth/demo/{role} | — | one-click sign-in as a shared demo account (role ∈ admin, user); 404 unless that account is configured; rate-limited per role — see Demo mode |
POST /api/users/auth/token | TokenRequest (email + password) | bearer login for mobile / API clients → {access_token, refresh_token, token_type, expires_in}; 401 for external/SSO users |
POST /api/users/auth/token/refresh | RefreshRequest (refresh_token) | rotates a refresh token into a new pair (old one revoked) |
DELETE /api/users/auth/token | RefreshRequest (refresh_token) | revokes a refresh token (idempotent) |
GET /api/users/auth/{provider}/login | — | OAuth: redirect to the IdP (provider ∈ configured set) |
GET /api/users/auth/{provider}/callback | ?code=&state= | OAuth: find-or-create user, set cookie, 303 to login_redirect_url |
Self-service endpoints
| Method + path | Body / response | Permission |
|---|---|---|
GET /api/users/me | → UserRead | active user |
PATCH /api/users/me | SelfProfileUpdate → UserRead | active user |
Admin endpoints (users.manage)
| Method + path | Body / response |
|---|---|
GET /api/users/admin | ?q=&status=&role=&verified=&sort=&order=&page=&per_page= → list[UserListItem] |
POST /api/users/admin | UserAdminCreate → UserListItem (201) — active+verified user with an admin-set password |
POST /api/users/admin/invite | UserInvite → UserListItem (201) |
PATCH /api/users/admin/{user_id} | UserDetailsUpdate → UserListItem — edit email + full name |
DELETE /api/users/admin/{user_id} | → 204 (hard delete; an admin cannot delete their own account → 400) |
PATCH /api/users/admin/{user_id}/disable | → UserListItem |
PATCH /api/users/admin/{user_id}/enable | → UserListItem |
PUT /api/users/admin/{user_id}/roles | RoleAssignment → UserListItem |
PATCH /api/users/admin/{user_id}/verify | → UserListItem (mark verified; idempotent) |
POST /api/users/admin/{user_id}/reset-password-link | → PasswordResetLink (409 for external/SSO users) |
POST /api/users/admin creates an active + verified user directly — no invite email, no verification flow; the admin sets the password. It returns 409 if the email is already taken and 400 for an invalid password. The matching admin UI (Create form, Edit details card, and a delete "danger zone") lives under /admin/users/ — see View routes and Inertia pages.
View routes
Public:
GET /users/login→Users/Login(shows dev-account buttons in dev)POST /users/logout→ 303 redirect, clears cookieGET /users/register→Users/Register(404 if signup disabled)GET /users/forgot-password→Users/ForgotPasswordGET /users/reset-password→Users/ResetPasswordGET /users/verify→Users/VerifyEmailGET /users/invite/accept→Users/AcceptInvite
Authenticated:
GET /users/me→Users/ProfilePATCH /users/me→ form action (redirects)
Admin (users.manage) — served from the module's admin_view_prefix, /admin/users:
GET /admin/users/→Users/Users/IndexGET /admin/users/add→Users/Users/AddPeople— one form for both flows;?mode=create|invitepicks whichGET /admin/users/{user_id}→Users/Users/Edit
The separate Create and Invite pages merged into AddPeople. Their old paths survive as 307 redirects carrying the mode: GET /admin/users/invite → /admin/users/add?mode=invite, and GET /admin/users/create → /admin/users/add?mode=create.
These pages moved out of /users/admin when the admin screens were gathered under /admin — the pre-move URLs still 301 for now, but link to the new ones. The /api/users/admin/* endpoints below are a separate contract and did not move.
Public contracts
from users.contracts.schemas import (
UserRead,
UserCreate,
UserUpdate,
UserInvite,
UserAdminCreate,
UserDetailsUpdate,
UserListItem,
RoleListItem,
RoleAssignment,
AcceptInviteRequest,
PasswordResetLink,
SelfProfileUpdate,
)
from users.contracts.events import (
UserRegistered,
UserInvited,
UserCreated,
UserDeleted,
UserDisabled,
RoleAssigned,
)| Class | Purpose |
|---|---|
UserRead | id, email, is_active, is_superuser, is_verified, is_external, full_name, tenant_id, disabled_at, last_login_at. |
UserAdminCreate | Admin create-user input: email, password, full_name, role_names. |
UserDetailsUpdate | Admin edit input: email, full_name. |
UserListItem | Admin list row; adds is_external, created_at, roles. |
RoleListItem | id, name, description, user_count. |
UserRegistered, UserInvited, UserCreated, UserDeleted, UserDisabled, RoleAssigned | Events — see Events. |
Models
User (table users_user)
| Column | Type | Notes |
|---|---|---|
id | UUID | PK |
email | str | unique, indexed; functional index on lower(email) |
hashed_password | str | None | nullable — external (SSO) users have no local password (see External / SSO users) |
is_active / is_superuser / is_verified | bool | |
is_external | bool | True for users provisioned via an external IdP; default False (server_default false) |
full_name | str | None | |
tenant_id | str | None | indexed; only set when multi-tenant |
disabled_at | datetime | None | |
last_login_at | datetime | None | indexed |
roles | relationship → Role via UserRole | eagerly loaded by AuthMiddleware |
Role (table users_role) — id, name (unique, indexed), description.
UserRole (table users_user_role) — composite-PK join table with assigned_at, assigned_by. The (user_id, role_id) PK is user-id-first; a separate index covers reverse lookups by role_id.
UserAccessToken (table users_access_token) — bearer API tokens; resolved by UsersAuthProvider on Authorization: Bearer requests (sessions remain the primary browser auth).
OAuthAccount (table users_oauth_account) — linked OAuth/OIDC accounts: oauth_name, account_id, account_email, access_token, refresh_token, expires_at.
RefreshToken (table users_refresh_token) — opaque refresh tokens for the bearer flow: token (PK), user_id, created_at, expires_at, revoked_at.
Two pre-seeded roles get fixed UUIDs so other modules can reference them safely:
| Role | UUID | Default permissions |
|---|---|---|
admin | 00000000-0000-0000-0000-000000000001 | implicitly all (admin bypass in RequiresPermission) |
user | 00000000-0000-0000-0000-000000000002 | users.self.profile, file_storage.{upload,download,delete} |
Settings
DB-backed via register_module_settings. Two values are read only from the env at module import time because they bootstrap token signing before the DB is reachable:
| Env var | Default | Purpose |
|---|---|---|
SM_USERS_RESET_PASSWORD_TOKEN_SECRET | dev-reset-token-secret-change-me | password-reset token signing |
SM_USERS_VERIFICATION_TOKEN_SECRET | dev-verify-token-secret-change-me | email-verify token signing |
Both must be replaced with non-placeholder values in production — the boot-time check refuses to start otherwise.
Everything else is DB-backed (initial values are pydantic defaults; edit under Users at /admin/settings/):
| Field | Default |
|---|---|
allow_signup | False |
require_verification | True |
login_redirect_url | "/dashboard/" (if the Dashboard module isn't installed, auto-falls back to the first other module that exposes view routes, or / only as a last resort) |
reset_password_token_lifetime_seconds | 3600 |
verification_token_lifetime_seconds | 604_800 (7 days) |
bearer_token_lifetime_seconds | 900 (15 min) |
refresh_token_lifetime_seconds | 2_592_000 (30 days) |
cookie_name | "sm_auth" |
cookie_max_age_seconds | 1_209_600 (14 days) |
cookie_secure | True (flipped to False in dev at startup) |
cookie_samesite | "lax" |
mailer | "console" (or "smtp") |
base_url | "http://localhost:8000" |
smtp_host / smtp_port / smtp_username / smtp_password / smtp_from / smtp_tls | SMTP config when mailer="smtp" |
login_rate_limit_failures | 5 |
login_rate_limit_window_seconds | 300 |
login_rate_limit_cooldown_seconds | 900 |
auth_rate_limit_attempts | 10 |
auth_rate_limit_window_seconds | 300 |
bootstrap_email, bootstrap_password, bootstrap_user_email, bootstrap_user_password | "" — see Bootstrap |
demo_mode | False — see Demo mode |
demo_admin_email | "demo-admin@example.com" — blank to stop offering the admin demo |
demo_user_email | "demo-user@example.com" — blank to stop offering the standard-user demo |
demo_admin_password / demo_user_password | "" (a random one is generated, so the account is reachable only through its button) |
demo_read_only | True |
oauth_google_client_id / oauth_google_client_secret | "" — Google OAuth |
oauth_github_client_id / oauth_github_client_secret | "" — GitHub OAuth |
oauth_microsoft_client_id / oauth_microsoft_client_secret / oauth_microsoft_tenant | "" / "" / "common" — Microsoft (Entra ID) |
oauth_oidc_client_id / oauth_oidc_client_secret / oauth_oidc_discovery_url / oauth_oidc_display_name | "" / "" / "" / "OIDC" — generic OIDC |
Permissions
| Code | Purpose |
|---|---|
users.manage | admin: list / invite / disable / role-assign |
users.self.profile | edit own profile (granted to user role) |
users.manage also unlocks account names in the audit log's entity column: the module registers its audit link with label_permission="users.manage", so a reader holding only audit_log.view sees the id of an edited account rather than its display name or email. See audit_log § Entity and actor names.
Menu
| Label | URL | Icon | Section | Group | Order | Roles |
|---|---|---|---|---|---|---|
Users | /admin/users/ | users | ADMIN_SIDEBAR | Access | 100 | ["admin"] |
Profile | /users/me | user | USER_DROPDOWN | — | 990 | logged-in |
Logout | /users/logout (POST) | log-out | USER_DROPDOWN | — | 999 | logged-in |
Events
| Event | Fields | Fired |
|---|---|---|
UserRegistered | user_id, email | on signup |
UserInvited | user_id, email, invited_by | on admin invite |
UserCreated | user_id, email, created_by | on admin create (POST /api/users/admin) |
UserDeleted | user_id | on admin delete |
UserDisabled | user_id | on admin disable |
RoleAssigned | user_id, role_name | once per role on PUT /admin/{user_id}/roles |
CLI
smpy users create-admin --email <e> --password <p> [--full-name <name>] [--force]— creates (or, with--force, updates) an admin user. Idempotent: re-running with the same email is a no-op without--force.
uv run smpy users create-admin --email admin@example.com --password changemeProgrammatically:
from users.bootstrap import create_admin
result = await create_admin(db, email="admin@example.com", password="...")
# result.user, result.created -> boolBootstrap (the first admin)
Two paths to seed the first admin:
- CLI —
smpy users create-admin .... - Env vars — set
SM_USERS_BOOTSTRAP_EMAIL+SM_USERS_BOOTSTRAP_PASSWORDbefore firstmake dev.bootstrap_admin_from_env(app)runs at startup and creates the admin if theusers_usertable is empty. Optionally seed a non-admin too viaSM_USERS_BOOTSTRAP_USER_EMAIL+SM_USERS_BOOTSTRAP_USER_PASSWORD.
Demo mode (hosting a showcase instance)
Turn demo_mode on and the sign-in card grows a button per demo account. One click signs the visitor straight in — no email, no password, no signup.
users.demo_mode = true
users.demo_admin_email = demo-admin@example.com
users.demo_user_email = demo-user@example.com
users.demo_read_only = true # default; leave it onSet these under Users at /admin/settings/, or seed them in the settings store before first boot. Changes apply live — the SettingsReloaded handler re-seeds the accounts and refreshes the cached ids, so there is no restart.
Two accounts, not one
An administrator and an ordinary standard user, because the two halves of the app look nothing alike. The admin surface — users, roles, settings, modules, audit history — is what the framework is for, and a visitor who only ever sees the end-user app never meets it. One who only ever sees /admin/* never meets the app you actually build for people. Offering both, and letting someone switch between them without signing out, is what makes a showcase instance answer "what is this?" in one visit.
Each is independently switchable: blank an email and that button disappears and its route starts answering 404 — the offer and the endpoint come from the same resolution, so hiding a button never leaves a live way in behind it. Blank both and demo_mode has nothing to turn on (logged, since you plainly meant something by switching it on).
What it does
- Seeds the accounts on every boot and every settings reload (
users.demo.ensure_demo_users). Idempotent, and it reconciles: the admin row carriesis_superuserand theadminrole, the user row carries neither, and repointing an email from one to the other demotes the row rather than leaving the old grant in place. One account failing to seed does not withdraw the other. - Never sends a password to the browser. Each button posts to
POST /api/users/auth/demo/{role}with an empty body and the server resolves the account itself. This is the difference between demo mode and the dev quick-login buttons, which paste real credentials into the form and are therefore development-only. Leave ademo_*_passwordblank and that account is seeded with a random secret nobody — including you — can type; set one only if you also intend to publish the credentials (for an API demo, say). - Refuses writes while
demo_read_onlyis on.DemoReadOnlyMiddlewarerejects every unsafe HTTP method from either demo session with403 {"detail": "DEMO_READ_ONLY"}, except signing out and the demo sign-in routes themselves — switching between the two accounts is the point of having two, so it must not require a sign-out. Inertia requests get the protocol's409+X-Inertia-Locationinstead, so a blocked save re-renders the page rather than throwing up an error modal. - Says so.
DemoBannerrenders a standing bar in every app shell, driven by thedemoshared prop. It is per session, not per install — you, signed in to your own account on the same instance, do not see it, and your writes are not touched.
demo_read_only = false
With an admin demo configured, this hands anyone who can reach your sign-in page a writable superuser: the settings editor (including the SMTP password and OAuth client secrets), the user table, and maintenance mode. It is allowed — an instance whose database is rebuilt on a timer has a legitimate reason to want a writable demo — but it logs users.demo.writable_admin at WARNING on every settings load. Do not run it against a database you care about. Blanking demo_admin_email is the way to have a writable demo without that exposure.
Limits worth knowing
- The accounts are shared. Two visitors exploring the same one at once see each other's state, and with
demo_read_only = falsethey can overwrite each other. Periodically resetting the database is the only real answer. demo_read_onlymatches on the session — the stamp a demo endpoint writes, or a session whoseuser_idis one of the demo accounts'. A bearer token is not covered directly, but minting one is itself aPOST, so a read-only demo cannot get hold of one.- The guard deliberately does not consult
demo_mode. Switching demo mode off withdraws the offer — it does not retract the sessions already handed out, and promoting those live cookies to writable superusers on the way out would be the opposite of what "turn the demo off" means. A stamped session stays read-only (and keeps its banner) until it signs out or lapses. To end the sessions themselves, disable the demo rows in the user editor; a reconcile will not re-enable them. - Signing in with real credentials (
POST /api/users/auth/login) is allowed from a demo session — it is how you stop being the demo, it takes a real password, and the rate limiter still applies. Every non-demo sign-in clears the stamp, so a browser that once held a demo session is never locked out of the ordinary login. - Each role's endpoint has its own
auth_rate_limit_*budget (the limiter keys on path + client IP), so a bot hammering one cannot lock a visitor out of the other. Each click mints a session row, so a demo instance under real traffic may want the budget raised. - The demo admin satisfies the first-run setup gate — an administrator exists, so
/setupnever appears. Seed your own admin first (smpy users create-admin, or theSM_USERS_BOOTSTRAP_*vars), because a read-only demo session cannot complete the wizard.
Mailer backends
mailer/ ships two implementations of the Mailer protocol:
| Backend | When to use | Configured via |
|---|---|---|
ConsoleMailer | dev — prints invite / verify / reset links to stdout | mailer="console" |
SmtpMailer | prod — talks SMTP | mailer="smtp" + smtp_* settings |
build_mailer(settings) returns the right instance based on users.mailer.
OAuth providers
OAuth/OIDC sign-in is DB-settings-driven: a provider is enabled simply by setting its client_id + client_secret (and, for generic OIDC, a discovery URL) in the settings UI under Users at /admin/settings/. No code change or restart — register_event_handlers rebuilds the client cache (app.state.users.oauth_clients) on the SettingsReloaded event, so changes take effect live. Providers with no credentials are silently skipped.
Built-in provider keys (the {provider} URL segment):
| Provider | Key | Notes |
|---|---|---|
google | ||
| GitHub | github | |
| Microsoft (Entra ID) | microsoft | oauth_microsoft_tenant: "common" (any account), "organizations" (work/school), or a tenant GUID |
| Generic OIDC | oidc | any provider exposing a discovery URL (Keycloak, Authentik, Auth0, Zitadel, …); discovery failure logs + disables rather than breaking boot |
A single dispatcher pair (/api/users/auth/{provider}/login + /callback) serves every provider; the client is resolved per request from the cache. The /callback returns a 303 redirect (not the stock fastapi-users 204) so Inertia lands on a real page, with the auth cookie attached. Find-or-create + email association goes through UserManager.oauth_callback (associate_by_email=True, is_verified_by_default=True); state CSRF uses the signed session cookie. Linked accounts are stored in users_oauth_account. A newly provisioned OAuth account is marked external (see below); an existing password account linked by email keeps its password and is left unchanged.
External / SSO users
Users created through an external IdP (Google, GitHub, Microsoft/Entra ID, or generic OIDC) are provisioned with is_external=True and no local password (hashed_password is NULL). The OAuth callback's find-or-create only nulls the password + marks external for accounts it creates — an existing password account that gets linked by email keeps its password and stays non-external. External users sign in only through their IdP; roles are still assigned locally like any other user.
Every password-credential path guards against external users, server-side:
| Action | Behaviour for an external user |
|---|---|
POST /api/users/auth/login (session) | 401 — treated like a missing user (a dummy bcrypt verify still runs, so timing doesn't leak that the account is SSO-only) |
POST /api/users/auth/token (bearer) | 401 — same guard as session login |
POST /api/users/auth/forgot-password | 200 but a silent no-op — preserves anti-enumeration |
POST /api/users/admin/{user_id}/reset-password-link | 409 — raises ExternalUserNoPasswordError; there is no password to reset |
In the admin UI, the user list and edit page show an "External · SSO" badge, and the password-reset action is hidden with an explanation. Disable / enable, role assignment, and delete work the same as for any other user.
UsersAuthProvider
UsersAuthProvider (registered on app.state.auth.auth_provider in register_settings) is the AuthProvider the auth module's AuthMiddleware delegates to. On each request resolve_user:
- For a
Bearertoken, looks the token up inusers_access_tokenand returns the active user. - Otherwise reads
session["user_id"], returns the cached context fromsession["user_ctx"]on a hit, else loads the User row with eagerly-loaded roles, builds aUserContext, caches it insession["user_ctx"], and clears stale session keys when the user is missing/disabled.
get_login_url() → /users/login, get_logout_url() → /users/logout. The provider's get_public_paths() declares these prefixes anonymous (in addition to the framework defaults /health, /static/, /api/docs, /api/redoc, /openapi.json, /i18n/, /):
/users/login, /users/register, /users/forgot-password, /users/reset-password, /users/verify, /users/invite/accept, /api/users/auth/, /api/users/register.
Anything else without a session redirects to /users/login (or returns 401 JSON for /api/* / bearer requests).
Roles cache
roles_cache.py keeps an in-memory list of RoleSummary(id, name) on app.state.users.roles_cache. Refreshed at startup and on demand via refresh_roles_cache(app). Used by the admin UI to render role pickers without hitting the DB on every request.
Inertia pages
Auth flow:
Users/Login.tsx,Users/Register.tsx,Users/ForgotPassword.tsx,Users/ResetPassword.tsx,Users/VerifyEmail.tsx,Users/AcceptInvite.tsx,Users/Profile.tsx.
Admin:
Users/Users/Index.tsx(list + Create button),Users/Users/Invite.tsx,Users/Users/Create.tsx,Users/Users/Edit.tsx.
Components (under pages/Users/components/):
AccountStatusCard.tsx— status block, including the External · SSO badge and the hidden-password-reset explanation for external users.DetailsCard.tsx— edit email + full name.DangerZone.tsx— delete-user action with confirmation.
Notes
cookie_secureis automatically flipped toFalsein dev (SM_ENVIRONMENT=development) so login works over plain HTTP. Don't override it in non-dev environments.LoginRateLimiterkeys login attempts bylowercased-email::client-ip. Counters live in-process (no Redis), so a multi-worker deployment has independent counters per worker — swap for a Redis-backed store when running more than one worker.- The functional
lower(email)index makes case-insensitive lookups fast on Postgres; on SQLite the lookup falls back toLOWER(email) = ?which still uses the regular index.