Users, invites and lifecycle
Companion to Identity and authentication. Everything here lives under
/api/v1; tenant-scoped routes need X-Tenant-ID.
Invitations
An invitation is a single-use, expiring link: {DASHBOARD_URL}/accept-invite?token=.... Only the
SHA-256 of the random token is stored (invites.token_hash); the plain token exists only in the
email. Clear-text password emails no longer exist.
| Endpoint | Auth | Notes |
|---|---|---|
POST /invites {email, role, account_id?, expires_in_hours?} | session, invites:write | 201. role is the key of one of the tenant's assignable roles (GET /users/assignable-roles), and you can only invite a role whose permissions you hold in full (403 otherwise); account_id (optional) restricts a user with a client-kind role to one account and is rejected for other kinds. A platform role and the customer role cannot be invited (400). Supports Idempotency-Key. |
GET /invites?status=&email=&page=&limit= | session, invites:read | status: pending, accepted, expired, revoked. |
POST /invites/{id}/resend | session | New token (old link dies), new expiry, email re-sent. 400 if accepted/revoked. |
DELETE /invites/{id} | session | Revokes. 204. |
GET /invites/validate?token= | public | {email, role, role_name, tenant_name, account_name, expires_at}; uniform 404 for unknown, expired, revoked or used tokens. |
POST /invites/accept {token, first_name, last_name, password} | public | Creates the user, grants tenant (and account) membership with granted_by = inviter, marks accepted, signs in with the same cookies as /auth/login. |
Statuses: pending -> accepted or revoked; expired is derived (pending past expires_at,
default 72 h). A new invite for the same email and scope revokes the older pending one.
An email that already has an active login only gets the grant added (roles are per tenant, so
the same person can hold different roles in different tenants; an existing role is replaced by the
invited one); its password and names are left alone (a login with no password yet, e.g. from
the CSV import, gets the chosen one). Incompatible or suspended accounts, and every other
failure, answer the same 404, so nothing reveals whether an email exists. If the account has MFA
the memberships are added but signed_in is false (no session without the second factor).
The legacy send_invite flag on POST /users and POST /accounts/{id}/members still exists but
now emails an invitation link instead of the password (POST /users adds Deprecation: true).
Lifecycle
users.status: active ↔ suspended, and deleted (soft delete). users.active is generated
from status/deleted_at; code never writes it.
| Endpoint | Effect |
|---|---|
POST /users/{id}/suspend, /unsuspend | Block/allow sign-in; suspend revokes all sessions. |
POST /users/{id}/unlock | Clears lockout and failure counter. |
POST /users/{id}/delete | Soft delete: status=deleted, deleted_at, all memberships removed, sessions revoked. |
POST /users/{id}/restore | Back to active, re-granted the tenant's member role. |
POST /users/{id}/revoke-sessions | {revoked: n}. |
POST /users/{id}/mfa/reset | Clears MFA secret and recovery codes, signs out. |
DELETE /users/{id} | Unchanged: removes the user from the current tenant only. |
Safeguards (single and bulk): not yourself (400), nobody holding permissions you lack (403), never the last active superadmin (409), deleted users must be restored first (409). The
email of a deleted user stays reserved; only an admin restore brings the account back. All
actions are audited with domain='user'.
Directory
GET /users filters: role, kind, status, mfa_enabled, locked, last_login_before/after,
escalation_level, search, sort (created_at|last_login_at|email), order, plus
include_deleted (admin). UserRead adds status, last_login_at, mfa_enabled,
locked_until, locked, escalation_level, created_at. PATCH /users/{id} accepts
escalation_level (L1..L5 or null, staff roles only: superadmin, tenant admin, operator).
Bulk: POST /users/bulk {action, user_ids[<=500], value?}, actions suspend, unsuspend,
delete, set_role, revoke_sessions, set_escalation_level. Always 200 with per-user
results[{user_id, ok, error, status_code}]; one failure does not stop the rest.
CSV: GET /users/export streams text/csv with the list filters (no secrets; formula-like
cells are apostrophe-prefixed). POST /users/import (multipart file, dry_run=true default)
reads email, role[, first_name, last_name, account_id, escalation_level], max 1000 rows / 1 MiB;
real runs create users with no password, grant memberships and email invitation links; errors are
reported per row.
Self profile and preferences
GET/PATCH /users/me (names, optionally embedded preferences), GET/PATCH /users/me/preferences (timezone validated by zoneinfo, locale, density
compact|comfortable, notification_channels subset of in_app|email|sse; null resets).
Email change: POST /users/me/email {new_email, password} mails a 1 h single-use link to the new
address (always 202, even if the address is taken); POST /users/me/email/confirm {token}
applies it and signs out the other sessions. Password change is POST /me/password (see
identity).
Idempotency
Idempotency-Key is honoured on POST /users, POST /invites and POST /users/bulk
(app/core/idempotency.py, table idempotency_keys, scope tenant + caller + key). Same key and
payload replays the stored response (Idempotent-Replayed: true); same key with a different
payload is 422; while the first call runs it is 409; failures release the key. Entries expire
after 24 h. Use run_idempotent(...) to add it to another endpoint.