Files
Warp/server/internal/adminapi/user.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

416 lines
13 KiB
Go

package adminapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// The endpoints about one user, addressed by full user ID.
// userDetail is GET /users/{userID}: the list row and what it takes a
// count to know.
type userDetail struct {
adminUser
Devices int `json:"devices"`
// JoinedRooms is absent on a server without rooms.
JoinedRooms *int `json:"joined_rooms,omitempty"`
// Media is absent on a server without a media repository.
Media *mediaResponse `json:"media,omitempty"`
}
// mediaResponse is a user's uploads against their quota.
type mediaResponse struct {
Uploads int `json:"uploads"`
Bytes int64 `json:"bytes"`
// QuotaBytes is the quota in force, 0 for no limit; CustomQuota says
// whether it was set for this user rather than taken from the config.
QuotaBytes int64 `json:"quota_bytes"`
CustomQuota bool `json:"custom_quota"`
}
// pathUser reads the {userID} path value: a user of this server.
func (a *AdminAPI) pathUser(r *http.Request) (string, error) {
raw := r.PathValue("userID")
localpart, err := a.users.ParseLocalUserID(raw)
switch {
case errors.Is(err, userapi.ErrForeignServer):
return "", httputil.InvalidParam("%s is not a user of this server", raw)
case err != nil:
return "", httputil.InvalidParam("%s is not a valid user ID", raw)
}
return localpart, nil
}
// pathAccount reads the {userID} path value and the account it names.
func (a *AdminAPI) pathAccount(r *http.Request) (storage.Account, error) {
localpart, err := a.pathUser(r)
if err != nil {
return storage.Account{}, err
}
account, err := a.users.Account(r.Context(), localpart)
switch {
case errors.Is(err, userapi.ErrNoSuchUser):
return storage.Account{}, httputil.NotFound("No such user")
case err != nil:
return storage.Account{}, err
}
return account, nil
}
func (a *AdminAPI) adminUserOf(acc storage.Account) adminUser {
return adminUser{
UserID: a.users.UserID(acc.Localpart),
DisplayName: acc.DisplayName,
AvatarURL: acc.AvatarURL,
Admin: acc.Admin,
Deactivated: acc.Deactivated,
CreatedAtMS: acc.CreatedAtMS,
}
}
// handleGetUser implements GET /_warp/admin/v1/users/{userID}.
func (a *AdminAPI) handleGetUser(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
return a.writeUser(w, r, account)
}
// writeUser answers with a user's detail, which is also what every change
// to a user answers with, so the panel can redraw from the response.
func (a *AdminAPI) writeUser(w http.ResponseWriter, r *http.Request, account storage.Account) error {
devices, err := a.users.Devices(r.Context(), account.Localpart)
if err != nil {
return err
}
resp := userDetail{adminUser: a.adminUserOf(account), Devices: len(devices)}
userID := a.users.UserID(account.Localpart)
if a.rooms != nil {
joined, err := a.rooms.JoinedRooms(r.Context(), userID)
if err != nil {
return err
}
n := len(joined)
resp.JoinedRooms = &n
}
if a.media != nil {
quota, err := a.media.Quota(r.Context(), userID)
if err != nil {
return err
}
resp.Media = &mediaResponse{
Uploads: quota.Usage.Count, Bytes: quota.Usage.Bytes,
QuotaBytes: quota.MaxBytes, CustomQuota: quota.Custom,
}
}
httputil.NoCache(w)
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// refuseSelf stops an admin taking their own admin flag or deactivating
// themselves. Both are one click from locking the last admin out of the
// panel, and both are easy to do by accident on a table of users; another
// admin can still do either.
func refuseSelf(auth userapi.Authenticated, localpart, what string) error {
if auth.Localpart == localpart {
return httputil.Forbidden("You cannot %s your own account; ask another admin", what)
}
return nil
}
// adminFlagRequest is the PUT /users/{userID}/admin body.
type adminFlagRequest struct {
Admin *bool `json:"admin"`
}
// handlePutUserAdmin implements PUT /_warp/admin/v1/users/{userID}/admin.
func (a *AdminAPI) handlePutUserAdmin(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
var req adminFlagRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Admin == nil {
return httputil.MissingParam("admin is required")
}
if !*req.Admin {
if err := refuseSelf(auth, account.Localpart, "remove the admin flag from"); err != nil {
return err
}
}
if account.Admin != *req.Admin {
if err := a.users.SetAdmin(r.Context(), account.Localpart, *req.Admin); err != nil {
return err
}
account.Admin = *req.Admin
a.audit(r.Context(), auth, auditUserAdmin, a.users.UserID(account.Localpart),
map[string]any{"admin": *req.Admin})
}
return a.writeUser(w, r, account)
}
// deactivateRequest is the POST /users/{userID}/deactivate body.
type deactivateRequest struct {
// Erase forgets the profile as well.
Erase bool `json:"erase"`
}
// handleDeactivateUser implements POST /_warp/admin/v1/users/{userID}/deactivate.
//
// It does what the user's own POST /account/deactivate does: every token
// stops working, the devices and their keys and pushers go, the user
// leaves every room, and with erase the display name and avatar are
// cleared first so the leave events carry neither. The name stays taken.
// Deactivating a deactivated account changes nothing.
func (a *AdminAPI) handleDeactivateUser(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
var req deactivateRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
if err := refuseSelf(auth, account.Localpart, "deactivate"); err != nil {
return err
}
if !account.Deactivated {
if err := a.users.DeactivateAccount(r.Context(), account.Localpart, req.Erase); err != nil {
return err
}
a.audit(r.Context(), auth, auditUserDeactivate, a.users.UserID(account.Localpart),
map[string]any{"erase": req.Erase})
}
if account, err = a.users.Account(r.Context(), account.Localpart); err != nil {
return err
}
return a.writeUser(w, r, account)
}
// handleReactivateUser implements POST /_warp/admin/v1/users/{userID}/reactivate.
//
// The account can log in again, with its old password unless an admin
// sets a new one. Nothing else comes back: the rooms were left and the
// devices are gone.
func (a *AdminAPI) handleReactivateUser(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
if account.Deactivated {
if err := a.users.ReactivateAccount(r.Context(), account.Localpart); err != nil {
return err
}
account.Deactivated = false
a.audit(r.Context(), auth, auditUserReactivate, a.users.UserID(account.Localpart), nil)
}
return a.writeUser(w, r, account)
}
// resetPasswordRequest is the POST /users/{userID}/password body.
type resetPasswordRequest struct {
Password string `json:"password"`
// LogoutDevices defaults to true: an admin resetting a password is
// usually doing it because somebody else has the account.
LogoutDevices *bool `json:"logout_devices"`
}
// handleResetPassword implements POST /_warp/admin/v1/users/{userID}/password.
func (a *AdminAPI) handleResetPassword(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
var req resetPasswordRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Password == "" {
return httputil.MissingParam("password is required")
}
if len(req.Password) > userapi.MaxPasswordLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword,
"Password must be at most %d bytes", userapi.MaxPasswordLength)
}
logout := req.LogoutDevices == nil || *req.LogoutDevices
if err := a.users.ChangePassword(r.Context(), account.Localpart, req.Password, logout, ""); err != nil {
return err
}
a.audit(r.Context(), auth, auditUserPassword, a.users.UserID(account.Localpart),
map[string]any{"logout_devices": logout})
return a.writeUser(w, r, account)
}
// adminDevice is one device in the device list.
type adminDevice struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
LastSeenMS int64 `json:"last_seen_ms"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// handleGetUserDevices implements GET /_warp/admin/v1/users/{userID}/devices.
func (a *AdminAPI) handleGetUserDevices(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
devices, err := a.users.Devices(r.Context(), account.Localpart)
if err != nil {
return err
}
out := make([]adminDevice, 0, len(devices))
for _, d := range devices {
out = append(out, adminDevice{
DeviceID: d.ID, DisplayName: d.DisplayName, CreatedAtMS: d.CreatedAtMS,
LastSeenMS: d.LastSeenMS, LastSeenIP: d.LastSeenIP, UserAgent: d.UserAgent,
})
}
httputil.NoCache(w)
httputil.WriteJSON(w, http.StatusOK, map[string]any{"devices": out})
return nil
}
// handleDeleteUserDevice implements
// DELETE /_warp/admin/v1/users/{userID}/devices/{deviceID}: log one
// session out, taking its keys and pushers with it.
func (a *AdminAPI) handleDeleteUserDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
deviceID := r.PathValue("deviceID")
if _, err := a.users.Device(r.Context(), account.Localpart, deviceID); errors.Is(err, userapi.ErrNoSuchDevice) {
return httputil.NotFound("No such device")
} else if err != nil {
return err
}
if err := a.users.DeleteDevices(r.Context(), account.Localpart, []string{deviceID}); err != nil {
return err
}
a.audit(r.Context(), auth, auditUserDeviceDelete, a.users.UserID(account.Localpart),
map[string]any{"device_id": deviceID})
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleDeleteUserDevices implements
// DELETE /_warp/admin/v1/users/{userID}/devices: log every session out.
func (a *AdminAPI) handleDeleteUserDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
account, err := a.pathAccount(r)
if err != nil {
return err
}
if err := a.users.DeleteAllDevices(r.Context(), account.Localpart); err != nil {
return err
}
a.audit(r.Context(), auth, auditUserDeviceDelete, a.users.UserID(account.Localpart),
map[string]any{"all": true})
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// requireMedia refuses the media endpoints on a server without a media
// repository.
func (a *AdminAPI) requireMedia() error {
if a.media == nil {
return httputil.NotFound("This server has no media repository")
}
return nil
}
// handleGetUserMedia implements GET /_warp/admin/v1/users/{userID}/media.
func (a *AdminAPI) handleGetUserMedia(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
if err := a.requireMedia(); err != nil {
return err
}
account, err := a.pathAccount(r)
if err != nil {
return err
}
return a.writeMedia(w, r, account)
}
func (a *AdminAPI) writeMedia(w http.ResponseWriter, r *http.Request, account storage.Account) error {
quota, err := a.media.Quota(r.Context(), a.users.UserID(account.Localpart))
if err != nil {
return err
}
httputil.NoCache(w)
httputil.WriteJSON(w, http.StatusOK, mediaResponse{
Uploads: quota.Usage.Count, Bytes: quota.Usage.Bytes,
QuotaBytes: quota.MaxBytes, CustomQuota: quota.Custom,
})
return nil
}
// quotaRequest is the PUT /users/{userID}/media/quota body.
type quotaRequest struct {
MaxBytes *int64 `json:"max_bytes"`
}
// handlePutMediaQuota implements PUT /_warp/admin/v1/users/{userID}/media/quota.
//
// A quota below what the user already has does not delete anything; it
// stops further uploads until they are back under it.
func (a *AdminAPI) handlePutMediaQuota(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := a.requireMedia(); err != nil {
return err
}
account, err := a.pathAccount(r)
if err != nil {
return err
}
var req quotaRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.MaxBytes == nil {
return httputil.MissingParam("max_bytes is required (0 for no limit)")
}
if *req.MaxBytes < 0 {
return httputil.InvalidParam("max_bytes must not be negative")
}
userID := a.users.UserID(account.Localpart)
if err := a.media.SetQuota(r.Context(), userID, *req.MaxBytes); err != nil {
return err
}
a.audit(r.Context(), auth, auditUserMediaQuota, userID, map[string]any{"max_bytes": *req.MaxBytes})
return a.writeMedia(w, r, account)
}
// handleDeleteMediaQuota implements DELETE
// /_warp/admin/v1/users/{userID}/media/quota: back to the configured
// default.
func (a *AdminAPI) handleDeleteMediaQuota(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := a.requireMedia(); err != nil {
return err
}
account, err := a.pathAccount(r)
if err != nil {
return err
}
userID := a.users.UserID(account.Localpart)
if err := a.media.ResetQuota(r.Context(), userID); err != nil {
return err
}
a.audit(r.Context(), auth, auditUserMediaQuota, userID, map[string]any{"max_bytes": nil})
return a.writeMedia(w, r, account)
}