Files
Warp/server/internal/storage/users.go
T
tajniak81andClaude Opus 5.5 a94794b866
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 9: application services and admin completion
Implements PLAN.md Phase 9. internal/appservice reads registration files
(appservice.config_files), matches namespaces the way Synapse does
(anchored at the start), and delivers events to each service as
transactions read from the roomserver's event stream at a cursor kept in
appservice_state. A transaction in flight is recorded, so after a restart
it is resent under the same ID; failures back off from 2s to 5m, and a
successful ping cuts the backoff short.

In the Client-Server API an as_token acts as the service's sender or, with
?user_id=, as any existing user in its namespace. Services register and
log in their users with m.login.application_service; exclusive user and
alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown
aliases and users in a namespace are asked of the service first. The
ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED
and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when
rate_limited is false, skip the rate limiter.

Users can change their password (POST /account/password) and deactivate
their account (POST /account/deactivate), both behind the password.
Deactivation revokes tokens, deletes devices, optionally erases the
profile and leaves every room through a new userapi.AccountObserver.

The admin API is complete: user detail, the admin flag, deactivation and
reactivation, password resets, devices, media quotas
(media.max_user_storage_bytes plus per-user overrides), registration
tokens with use limits and expiry (registration.require_token), server
notices (internal/servernotices, server_notices.*), an editor for the
federation allow/deny lists persisted in server_settings, a delivery view
of the application services, and an audit log of every change.

Migration 0011 adds the registration token, server notice room, media
quota, audit log and appservice cursor tables. Policy lists are now
returned sorted. The Complement entrypoint loads the registrations
Complement mounts in /complement/appservice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:58:28 +02:00

230 lines
7.8 KiB
Go

package storage
import (
"context"
"errors"
)
// Errors the user store returns for the cases a caller has to tell apart.
// Everything else comes back wrapped and becomes a 500.
var (
// ErrUserExists is returned by CreateAccount for a taken localpart.
ErrUserExists = errors.New("user already exists")
// ErrNoSuchUser is returned for an unknown localpart.
ErrNoSuchUser = errors.New("no such user")
// ErrNoSuchDevice is returned for an unknown device of a known user.
ErrNoSuchDevice = errors.New("no such device")
// ErrNoSuchToken is returned for an access or refresh token that is
// unknown or has already been rotated away.
ErrNoSuchToken = errors.New("no such token")
// ErrNoSuchAccountData is returned for an account data type the user
// has never set.
ErrNoSuchAccountData = errors.New("no such account data")
)
// Account is a local user.
//
// Only the localpart is stored: the server name is fixed for the lifetime of
// a deployment — it is baked into every ID — so repeating it on every row
// would buy nothing. Virtual hosting, if it ever happens, is a migration.
//
// Timestamps throughout the user store are milliseconds since the Unix
// epoch, the unit the Client-Server API uses on the wire. Nothing needs
// converting on the way in or out, and both SQL drivers hold the same value.
type Account struct {
Localpart string
DisplayName string
AvatarURL string
Admin bool
Deactivated bool
CreatedAtMS int64
}
// NewAccount describes an account to create.
type NewAccount struct {
Localpart string
// PasswordHash is an encoded argon2id hash, or empty for an account
// that cannot log in with a password.
PasswordHash string
DisplayName string
Admin bool
CreatedAtMS int64
}
// Device is one login session's device record.
type Device struct {
Localpart string
ID string
DisplayName string
CreatedAtMS int64
LastSeenMS int64
LastSeenIP string
UserAgent string
}
// NewSession is a device plus the tokens issued for it: one login or one
// registration. The store writes all of it in a single transaction, so a
// client never sees a device without a working token, or the reverse.
//
// Tokens are stored as hashes. They are bearer credentials that by default
// never expire; hashing costs one SHA-256 per request and keeps a leaked
// database dump from handing over live sessions.
type NewSession struct {
Localpart string
DeviceID string
DisplayName string
CreatedAtMS int64
AccessTokenHash string
// AccessTokenExpiresAtMS is 0 for a token that never expires.
AccessTokenExpiresAtMS int64
// RefreshTokenHash is empty when the client did not ask for one.
RefreshTokenHash string
RefreshTokenExpiresAtMS int64
LastSeenIP string
UserAgent string
}
// RefreshedSession rotates the token pair of an existing device.
type RefreshedSession struct {
// RefreshTokenHash identifies the token being spent.
RefreshTokenHash string
NewAccessTokenHash string
AccessTokenExpiresAtMS int64
NewRefreshTokenHash string
RefreshTokenExpiresAtMS int64
RefreshedAtMS int64
}
// TokenInfo is everything the auth middleware needs from an access token, in
// one query: who owns it, on which device, and whether the account may still
// be used.
type TokenInfo struct {
Localpart string
DeviceID string
Admin bool
Deactivated bool
// ExpiresAtMS is 0 for a token that never expires.
ExpiresAtMS int64
}
// AccountFilter narrows and pages the admin user list.
type AccountFilter struct {
// Search matches a substring of the localpart or the display name,
// case-insensitively. Empty matches everything.
Search string
// Limit caps the returned rows; 0 means the store's own default.
Limit int
// Offset skips rows, for paging.
Offset int
}
// Stats are the counts the admin dashboard shows.
type Stats struct {
Users int
Admins int
Deactivated int
Devices int
}
// UserStore persists accounts, devices, tokens and account data: the
// storage half of the userapi component.
//
// Every method takes a localpart the caller has already normalised
// (lower-cased and validated); the store does no interpretation of its own.
type UserStore interface {
// CreateAccount inserts an account, returning ErrUserExists when the
// localpart is taken.
CreateAccount(ctx context.Context, acc NewAccount) (Account, error)
// Account reads one account, returning ErrNoSuchUser if it is absent.
Account(ctx context.Context, localpart string) (Account, error)
// PasswordHash returns the stored argon2id hash, which is empty for an
// account without a password.
PasswordHash(ctx context.Context, localpart string) (string, error)
// SetPasswordHash replaces the password hash.
SetPasswordHash(ctx context.Context, localpart, hash string) error
// SetAdmin grants or revokes the admin flag.
SetAdmin(ctx context.Context, localpart string, admin bool) error
// SetDeactivated sets or clears the deactivated flag.
SetDeactivated(ctx context.Context, localpart string, deactivated bool) error
// SetDisplayName updates the profile display name.
SetDisplayName(ctx context.Context, localpart, displayName string) error
// SetAvatarURL updates the profile avatar.
SetAvatarURL(ctx context.Context, localpart, avatarURL string) error
// ListAccounts returns a page of accounts ordered by localpart, plus
// the total number of accounts matching the filter.
ListAccounts(ctx context.Context, filter AccountFilter) ([]Account, int, error)
// Stats returns the aggregate counts for the admin dashboard.
Stats(ctx context.Context) (Stats, error)
// CreateSession creates or replaces a device and issues its tokens.
// Re-using an existing device ID revokes the tokens that device had,
// which is what the spec requires of a login naming a device already
// in use.
CreateSession(ctx context.Context, s NewSession) (Device, error)
// RefreshSession spends a refresh token and issues a new pair,
// returning the owner of the device. It returns ErrNoSuchToken if the
// token is unknown or has already been spent.
RefreshSession(ctx context.Context, r RefreshedSession) (TokenInfo, error)
// TokenInfo resolves an access token hash to its owner, returning
// ErrNoSuchToken when there is no such token. An expired token is
// returned with its expiry rather than rejected here, so the caller can
// answer with the soft logout the spec asks for.
TokenInfo(ctx context.Context, tokenHash string) (TokenInfo, error)
// TouchDevice records the last-seen timestamp, address and user agent
// of a device. It runs on authenticated requests, so it has to stay a
// single cheap statement.
TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error
// Device reads one device, returning ErrNoSuchDevice if it is absent.
Device(ctx context.Context, localpart, deviceID string) (Device, error)
// Devices lists a user's devices, ordered by device ID.
Devices(ctx context.Context, localpart string) ([]Device, error)
// SetDeviceDisplayName renames a device, returning ErrNoSuchDevice if
// it is absent.
SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error
// DeleteDevices removes devices and every token issued to them,
// returning how many existed. Unknown device IDs are ignored.
DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error)
// DeleteAllDevices removes every device of a user and their tokens.
DeleteAllDevices(ctx context.Context, localpart string) (int, error)
// AccountData reads one account data blob; roomID is empty for global
// account data. It returns ErrNoSuchAccountData when unset.
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
// SetAccountData writes one account data blob, overwriting any previous
// value.
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
}