feat: add break-glass user email update (#29256)

Adds an owner-only experimental API and hidden CLI command for
break-glass user email corrections.

The operation verifies the existing email, updates it transactionally,
clears outstanding password-reset state, revokes the target user's Coder
sessions and API tokens, and records successful and failed authenticated
attempts in the audit log. External-auth links remain unchanged.

Generated by Coder Agents.
This commit is contained in:
Cian Johnston
2026-09-14 14:33:36 +01:00
committed by GitHub
parent 1321b41854
commit 2cf819bfca
19 changed files with 915 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) updateUserEmail() *serpent.Command {
var (
oldEmail string
newEmail string
)
cmd := &serpent.Command{
Use: "update-user-email",
Short: "Update a user's email address (break-glass; experimental)",
Hidden: true,
Options: serpent.OptionSet{
{
Flag: "old-email",
Description: "Current email address of the user to update.",
Required: true,
Value: serpent.StringOf(&oldEmail),
},
{
Flag: "new-email",
Description: "New email address to assign to the user.",
Required: true,
Value: serpent.StringOf(&newEmail),
},
cliui.SkipPromptOption(),
},
Handler: func(inv *serpent.Invocation) error {
if oldEmail == "" {
return xerrors.Errorf("--old-email must not be blank")
}
if newEmail == "" {
return xerrors.Errorf("--new-email must not be blank")
}
client, err := r.InitClient(inv)
if err != nil {
return err
}
_, _ = fmt.Fprintf(inv.Stdout,
"This will update the email address for the account currently using %q to %q.\n"+
"All Coder sessions and API tokens for that user will be revoked.\n"+
"If the user logs in with an external identity provider, it may overwrite the email when the user next signs in.\n",
oldEmail, newEmail,
)
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm email update?",
IsConfirm: true,
Default: cliui.ConfirmNo,
})
if err != nil {
return err
}
err = client.UpdateUserEmail(inv.Context(), codersdk.UpdateUserEmailRequest{
OldEmail: oldEmail,
NewEmail: newEmail,
})
if err != nil {
return xerrors.Errorf("update user email: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Updated user email from %s to %s.\n", oldEmail, newEmail)
return nil
},
}
return cmd
}
+191
View File
@@ -0,0 +1,191 @@
package cli_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestUpdateUserEmail(t *testing.T) {
t.Parallel()
t.Run("CommandReachable", func(t *testing.T) {
t.Parallel()
root := getRoot(t)
var found *serpent.Command
root.Walk(func(cmd *serpent.Command) {
if cmd.Name() == "update-user-email" {
found = cmd
}
})
require.NotNil(t, found, "update-user-email command not found under exp")
require.True(t, found.Hidden, "command should be hidden")
})
t.Run("MissingOldEmail", func(t *testing.T) {
t.Parallel()
inv, _ := clitest.New(t, "exp", "update-user-email", "--new-email", "new@example.com")
err := inv.Run()
require.Error(t, err)
require.Contains(t, err.Error(), "old-email")
})
t.Run("MissingNewEmail", func(t *testing.T) {
t.Parallel()
inv, _ := clitest.New(t, "exp", "update-user-email", "--old-email", "old@example.com")
err := inv.Run()
require.Error(t, err)
require.Contains(t, err.Error(), "new-email")
})
t.Run("DeclinePrompt", func(t *testing.T) {
t.Parallel()
// Use a channel closed by the handler to detect whether the API is called.
apiCalled := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(apiCalled)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "exp", "update-user-email",
"--old-email", "old@example.com",
"--new-email", "new@example.com",
)
clitest.SetupConfig(t, client, root)
inv.Stdin = strings.NewReader("no\n")
ctx := testutil.Context(t, testutil.WaitShort)
done := make(chan struct{})
var runErr error
go func() {
defer close(done)
runErr = inv.Run()
}()
testutil.TryReceive(ctx, t, done)
require.ErrorIs(t, runErr, cliui.ErrCanceled)
// Verify the API was not called after the command returned.
select {
case <-apiCalled:
t.Fatal("API should not be called when prompt is declined")
default:
}
})
// AcceptPrompt runs a full end-to-end test against a real Coder server: it
// creates a second user, invokes the CLI as an admin, confirms the prompt,
// and asserts both the command output and the actual stored email.
t.Run("AcceptPrompt", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitShort)
oldEmail := member.Email
newEmail := "updated-" + oldEmail
inv, root := clitest.New(t, "exp", "update-user-email",
"--old-email", oldEmail,
"--new-email", newEmail,
)
//nolint:gocritic // This break-glass command is restricted to deployment owners.
clitest.SetupConfig(t, client, root)
inv.Stdin = strings.NewReader("yes\n")
var outBuf bytes.Buffer
inv.Stdout = &outBuf
require.NoError(t, inv.Run())
out := outBuf.String()
require.Contains(t, out, oldEmail)
require.Contains(t, out, newEmail)
require.Contains(t, out, "sessions and API tokens")
require.Contains(t, out, "external identity provider")
require.Contains(t, out, "Updated user email from "+oldEmail+" to "+newEmail+".")
// Verify the email was actually persisted.
updated, err := client.User(ctx, member.ID.String())
require.NoError(t, err)
require.Equal(t, newEmail, updated.Email)
})
t.Run("YesSkipsPrompt", func(t *testing.T) {
t.Parallel()
var gotBody codersdk.UpdateUserEmailRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := json.NewDecoder(r.Body).Decode(&gotBody)
assert.NoError(t, err)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "exp", "update-user-email",
"--old-email", "old@example.com",
"--new-email", "new@example.com",
"--yes",
)
clitest.SetupConfig(t, client, root)
var outBuf bytes.Buffer
inv.Stdout = &outBuf
require.NoError(t, inv.Run())
require.Contains(t, outBuf.String(), "Updated user email from old@example.com to new@example.com.")
require.Equal(t, "old@example.com", gotBody.OldEmail)
require.Equal(t, "new@example.com", gotBody.NewEmail)
})
t.Run("APIError", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
}))
t.Cleanup(srv.Close)
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "exp", "update-user-email",
"--old-email", "old@example.com",
"--new-email", "new@example.com",
"--yes",
)
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.Error(t, err)
require.Contains(t, err.Error(), "update user email")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "internal server error")
})
}
+1
View File
@@ -171,6 +171,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
r.promptExample(),
r.rptyCommand(),
r.syncCommand(),
r.updateUserEmail(),
}
}
+53
View File
@@ -246,6 +246,42 @@ const docTemplate = `{
}
}
},
"/api/experimental/users/email": {
"put": {
"consumes": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Update user email",
"operationId": "update-user-email-experimental",
"parameters": [
{
"description": "Update email request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserEmailRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/users/{user}/skills": {
"get": {
"produces": [
@@ -29703,6 +29739,23 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateUserEmailRequest": {
"type": "object",
"required": [
"new_email",
"old_email"
],
"properties": {
"new_email": {
"type": "string",
"format": "email"
},
"old_email": {
"type": "string",
"format": "email"
}
}
},
"codersdk.UpdateUserNotificationPreferences": {
"type": "object",
"properties": {
+46
View File
@@ -211,6 +211,38 @@
}
}
},
"/api/experimental/users/email": {
"put": {
"consumes": ["application/json"],
"tags": ["Users"],
"summary": "Update user email",
"operationId": "update-user-email-experimental",
"parameters": [
{
"description": "Update email request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserEmailRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/users/{user}/skills": {
"get": {
"produces": ["application/json"],
@@ -27333,6 +27365,20 @@
}
}
},
"codersdk.UpdateUserEmailRequest": {
"type": "object",
"required": ["new_email", "old_email"],
"properties": {
"new_email": {
"type": "string",
"format": "email"
},
"old_email": {
"type": "string",
"format": "email"
}
}
},
"codersdk.UpdateUserNotificationPreferences": {
"type": "object",
"properties": {
+5
View File
@@ -1327,6 +1327,11 @@ func New(options *Options) *API {
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
)
r.Route("/users/email", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Put("/", api.putUserEmailExperimental)
})
// NOTE(DanielleMaywood):
r.Route("/users/{user}/skills", func(r chi.Router) {
r.Use(
+16
View File
@@ -8326,6 +8326,22 @@ func (q *querier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error
return deleteQ(q.log, q.auth, q.db.GetUserByID, q.db.UpdateUserDeletedByID)(ctx, id)
}
func (q *querier) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) {
// Resolve the existing user by old email to obtain the RBAC object for
// authorization. The handler enforces the built-in owner role gate; this
// check adds defense-in-depth at the database layer.
existing, err := q.db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Email: arg.OldEmail,
})
if err != nil {
return database.User{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, existing); err != nil {
return database.User{}, err
}
return q.db.UpdateUserEmail(ctx, arg)
}
func (q *querier) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error {
user, err := q.db.GetUserByID(ctx, arg.ID)
if err != nil {
+7
View File
@@ -3159,6 +3159,13 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserDeletedByID(gomock.Any(), u.ID).Return(nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionDelete).Returns()
}))
s.Run("UpdateUserEmail", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserEmailParams{OldEmail: u.Email, NewEmail: "new@example.com", UpdatedAt: u.UpdatedAt}
dbm.EXPECT().GetUserByEmailOrUsername(gomock.Any(), database.GetUserByEmailOrUsernameParams{Email: u.Email}).Return(u, nil).AnyTimes()
dbm.EXPECT().UpdateUserEmail(gomock.Any(), arg).Return(u, nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdate).Returns(u)
}))
s.Run("UpdateUserGithubComUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserGithubComUserIDParams{ID: u.ID}
+8
View File
@@ -5801,6 +5801,14 @@ func (m queryMetricsStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UU
return r0
}
func (m queryMetricsStore) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserEmail(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserEmail").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserEmail").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error {
start := time.Now()
r0 := m.s.UpdateUserGithubComUserID(ctx, arg)
+15
View File
@@ -10962,6 +10962,21 @@ func (mr *MockStoreMockRecorder) UpdateUserDeletedByID(ctx, id any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateUserDeletedByID), ctx, id)
}
// UpdateUserEmail mocks base method.
func (m *MockStore) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserEmail", ctx, arg)
ret0, _ := ret[0].(database.User)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserEmail indicates an expected call of UpdateUserEmail.
func (mr *MockStoreMockRecorder) UpdateUserEmail(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserEmail", reflect.TypeOf((*MockStore)(nil).UpdateUserEmail), ctx, arg)
}
// UpdateUserGithubComUserID mocks base method.
func (m *MockStore) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error {
m.ctrl.T.Helper()
+1
View File
@@ -1587,6 +1587,7 @@ type sqlcQuerier interface {
UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateUserChatCustomPromptParams) (UserConfig, error)
UpdateUserCodeDiffDisplayMode(ctx context.Context, arg UpdateUserCodeDiffDisplayModeParams) (string, error)
UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error
UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) (User, error)
UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error
UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
+71
View File
@@ -19106,3 +19106,74 @@ func sessionFamilyCounts(t *testing.T, data json.RawMessage) map[codersdk.AppFam
require.NoError(t, err)
return counts
}
func TestUpdateUserEmail(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
ctx := context.Background()
// SQL-specific: the WHERE clause matches old_email case-insensitively.
t.Run("MatchesCaseInsensitively", func(t *testing.T) {
t.Parallel()
origEmail := "CaseSensitive" + testutil.GetRandomName(t) + "@example.com"
user := dbgen.User(t, db, database.User{Email: origEmail})
updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
OldEmail: strings.ToLower(origEmail),
NewEmail: "newcase" + testutil.GetRandomName(t) + "@example.com",
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
require.Equal(t, user.ID, updated.ID, "should match the same user case-insensitively")
})
// SQL-specific: the WHERE clause excludes soft-deleted users.
t.Run("DoesNotMatchDeletedUsers", func(t *testing.T) {
t.Parallel()
user := dbgen.User(t, db, database.User{})
err := db.UpdateUserDeletedByID(ctx, user.ID)
require.NoError(t, err)
_, err = db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
OldEmail: user.Email,
NewEmail: "shouldfail@example.com",
UpdatedAt: dbtime.Now(),
})
require.ErrorIs(t, err, sql.ErrNoRows)
})
// SQL-specific: new_email is stored verbatim (no lower-casing on write).
t.Run("PreservesNewEmailCasing", func(t *testing.T) {
t.Parallel()
user := dbgen.User(t, db, database.User{})
mixedCase := "Mixed.Case." + testutil.GetRandomName(t) + "@Example.COM"
updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
OldEmail: user.Email,
NewEmail: mixedCase,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
require.Equal(t, mixedCase, updated.Email, "new email casing must be preserved exactly")
})
// SQL-specific: the unique index name is users_email_lower_idx and the
// constraint fires on a case-variant collision.
t.Run("RejectsUniqueEmailCollision", func(t *testing.T) {
t.Parallel()
user1 := dbgen.User(t, db, database.User{})
user2 := dbgen.User(t, db, database.User{})
_, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
OldEmail: user1.Email,
NewEmail: strings.ToUpper(user2.Email),
UpdatedAt: dbtime.Now(),
})
require.True(t, database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex),
"expected unique_violation on users_email_lower_idx, got: %v", err)
})
}
+48
View File
@@ -31815,6 +31815,54 @@ func (q *sqlQuerier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) er
return err
}
const updateUserEmail = `-- name: UpdateUserEmail :one
UPDATE
users
SET
email = $1,
updated_at = $2,
hashed_one_time_passcode = NULL,
one_time_passcode_expires_at = NULL
WHERE
LOWER(email) = LOWER($3)
AND deleted = false
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros
`
type UpdateUserEmailParams struct {
NewEmail string `db:"new_email" json:"new_email"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OldEmail string `db:"old_email" json:"old_email"`
}
func (q *sqlQuerier) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUserEmail, arg.NewEmail, arg.UpdatedAt, arg.OldEmail)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
&i.RBACRoles,
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
&i.Name,
&i.GithubComUserID,
&i.HashedOneTimePasscode,
&i.OneTimePasscodeExpiresAt,
&i.IsSystem,
&i.IsServiceAccount,
&i.ChatSpendLimitMicros,
)
return i, err
}
const updateUserGithubComUserID = `-- name: UpdateUserGithubComUserID :exec
UPDATE
users
+13
View File
@@ -396,6 +396,19 @@ SET
WHERE
id = $1;
-- name: UpdateUserEmail :one
UPDATE
users
SET
email = @new_email,
updated_at = @updated_at,
hashed_one_time_passcode = NULL,
one_time_passcode_expires_at = NULL
WHERE
LOWER(email) = LOWER(@old_email)
AND deleted = false
RETURNING *;
-- name: UpdateUserDeletedByID :exec
UPDATE
users
+152
View File
@@ -7,8 +7,10 @@ import (
"fmt"
"net/http"
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -866,6 +868,156 @@ func (*API) userLoginType(rw http.ResponseWriter, r *http.Request) {
})
}
type updateUserEmailAuditFields struct {
OldEmail string `json:"old_email,omitempty"`
NewEmail string `json:"new_email,omitempty"`
}
// putUserEmailExperimental updates a user's email address and revokes all of
// their Coder credentials.
//
// @Summary Update user email
// @ID update-user-email-experimental
// @Security CoderSessionToken
// @Accept json
// @Tags Users
// @Param request body codersdk.UpdateUserEmailRequest true "Update email request"
// @Success 204
// @Router /api/experimental/users/email [put]
// @x-apidocgen {"skip": true}
func (api *API) putUserEmailExperimental(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
auditor = *api.Auditor.Load()
auditFields = &updateUserEmailAuditFields{}
aReq, done = audit.InitRequest[database.User](rw, &audit.RequestParams{
Audit: auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
AdditionalFields: auditFields,
})
)
defer done()
actor, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get acting user.",
Detail: err.Error(),
})
return
}
aReq.Old = actor
if !slices.Contains(actor.RBACRoles, rbac.RoleOwner().String()) {
httpapi.ResourceNotFound(rw)
return
}
var rawReq struct {
OldEmail string `json:"old_email"`
NewEmail string `json:"new_email"`
}
if !httpapi.Read(ctx, rw, r, &rawReq) {
return
}
auditFields.OldEmail = rawReq.OldEmail
auditFields.NewEmail = rawReq.NewEmail
req := codersdk.UpdateUserEmailRequest{
OldEmail: rawReq.OldEmail,
NewEmail: rawReq.NewEmail,
}
if err := httpapi.Validate.Struct(req); err != nil {
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
apiErrors := make([]codersdk.ValidationError, 0, len(validationErrors))
for _, validationError := range validationErrors {
apiErrors = append(apiErrors, codersdk.ValidationError{
Field: validationError.Field(),
Detail: fmt.Sprintf("Validation failed for tag %q with value: \"%v\"", validationError.Tag(), validationError.Value()),
})
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Validation failed.",
Validations: apiErrors,
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error validating request body payload.",
Detail: err.Error(),
})
return
}
var updated database.User
err = api.Database.InTx(func(tx database.Store) error {
target, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Email: req.OldEmail,
})
if err != nil {
return err
}
aReq.Old = target
if target.ID == actor.ID {
return errUpdateUserEmailSelf
}
if strings.EqualFold(req.OldEmail, req.NewEmail) {
return errUpdateUserEmailUnchanged
}
updated, err = tx.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
OldEmail: req.OldEmail,
NewEmail: req.NewEmail,
UpdatedAt: dbtime.Now(),
})
if err != nil {
return err
}
//nolint:gocritic // This break-glass operation must revoke all API keys
// owned by the target user, not only keys visible to the acting owner.
return tx.DeleteAPIKeysByUserID(dbauthz.AsAPIKeyRevoker(ctx, target.ID), target.ID)
}, nil)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
httpapi.ResourceNotFound(rw)
case errors.Is(err, errUpdateUserEmailSelf):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "You cannot update your own email address.",
})
case errors.Is(err, errUpdateUserEmailUnchanged):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The old and new email addresses must differ beyond letter casing.",
})
case database.IsUniqueViolation(err, database.UniqueIndexUsersEmail),
database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "A user with the new email address already exists.",
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update user email.",
Detail: err.Error(),
})
}
return
}
aReq.New = updated
rw.WriteHeader(http.StatusNoContent)
}
var (
errUpdateUserEmailSelf = xerrors.New("cannot update own email")
errUpdateUserEmailUnchanged = xerrors.New("old and new email are equal")
)
// @Summary Update user profile
// @ID update-user-profile
// @Security CoderSessionToken
+160
View File
@@ -2,6 +2,8 @@ package coderd_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"slices"
@@ -1212,6 +1214,164 @@ func TestNotifyCreatedUser(t *testing.T) {
})
}
func TestUpdateUserEmailExperimental(t *testing.T) {
t.Parallel()
// Share one server because these assertions do not require isolated state.
auditor := audit.NewMock()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor})
owner := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Only built-in deployment owners may use the endpoint.
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin())
_, authTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
auditor.ResetLogs()
err := userAdminClient.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
OldEmail: authTarget.Email,
NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()),
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
unchanged, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), authTarget.ID)
require.NoError(t, err)
require.Equal(t, authTarget.Email, unchanged.Email)
logs := auditor.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, int32(http.StatusNotFound), logs[0].StatusCode)
require.JSONEq(t, `{}`, string(logs[0].AdditionalFields))
// Owners cannot update their own email.
ownerUser, err := client.User(ctx, owner.UserID.String())
require.NoError(t, err)
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
OldEmail: ownerUser.Email,
NewEmail: fmt.Sprintf("self-%s@example.com", uuid.NewString()),
})
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
// Invalid inputs do not change user state.
_, validTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
for _, req := range []codersdk.UpdateUserEmailRequest{
{OldEmail: " " + validTarget.Email, NewEmail: "new@example.com"},
{OldEmail: validTarget.Email, NewEmail: validTarget.Email},
{OldEmail: strings.ToUpper(validTarget.Email), NewEmail: validTarget.Email},
} {
err := client.UpdateUserEmail(ctx, req)
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
}
// The old email must exist and the new email must be available.
_, conflictTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
_, existingUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
OldEmail: fmt.Sprintf("missing-%s@example.com", uuid.NewString()),
NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()),
})
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
OldEmail: conflictTarget.Email,
NewEmail: strings.ToUpper(existingUser.Email),
})
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
// Login type, status, and the target's owner role do not prevent updates.
for _, tc := range []struct {
name string
loginType database.LoginType
role rbac.RoleIdentifier
status database.UserStatus
}{
{name: "OIDC", loginType: database.LoginTypeOIDC},
{name: "GitHub", loginType: database.LoginTypeGithub},
{name: "Dormant", status: database.UserStatusDormant},
{name: "Suspended", status: database.UserStatusSuspended},
{name: "Owner", role: rbac.RoleOwner()},
} {
roles := []rbac.RoleIdentifier{}
if tc.role.Name != "" {
roles = append(roles, tc.role)
}
_, variant := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, roles...)
if tc.loginType != "" {
variantUpdated, err := db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{
UserID: variant.ID,
NewLoginType: tc.loginType,
})
require.NoError(t, err)
variant.LoginType = codersdk.LoginType(variantUpdated.LoginType)
}
if tc.status != "" {
_, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
ID: variant.ID,
Status: tc.status,
UpdatedAt: dbtime.Now(),
UserIsSeen: false,
})
require.NoError(t, err)
}
newVariantEmail := fmt.Sprintf("variant-%s@example.com", uuid.NewString())
err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
OldEmail: variant.Email,
NewEmail: newVariantEmail,
})
require.NoError(t, err, "variant %s should succeed", tc.name)
}
// The API clears OTP state, revokes tokens, and records the audit event.
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
oldEmail := member.Email
newEmail := fmt.Sprintf("Updated-%s@example.com", uuid.NewString())
oldToken := memberClient.SessionToken()
extraToken, err := client.CreateToken(ctx, member.ID.String(), codersdk.CreateTokenRequest{})
require.NoError(t, err)
err = db.UpdateUserHashedOneTimePasscode(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedOneTimePasscodeParams{
ID: member.ID,
HashedOneTimePasscode: []byte("hashed-passcode"),
OneTimePasscodeExpiresAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
})
require.NoError(t, err)
auditor.ResetLogs()
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
OldEmail: oldEmail,
NewEmail: newEmail,
})
require.NoError(t, err)
sideEffectsUser, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.ID)
require.NoError(t, err)
require.Nil(t, sideEffectsUser.HashedOneTimePasscode)
require.False(t, sideEffectsUser.OneTimePasscodeExpiresAt.Valid)
for _, token := range []string{oldToken, extraToken.Key} {
revokedClient := codersdk.New(client.URL, codersdk.WithSessionToken(token))
_, err := revokedClient.User(ctx, codersdk.Me)
require.Error(t, err)
}
_, err = client.User(ctx, codersdk.Me)
require.NoError(t, err)
logs = auditor.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, owner.UserID, logs[0].UserID)
require.Equal(t, member.ID, logs[0].ResourceID)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, int32(http.StatusNoContent), logs[0].StatusCode)
var fields map[string]string
require.NoError(t, json.Unmarshal(logs[0].AdditionalFields, &fields))
require.Equal(t, oldEmail, fields["old_email"])
require.Equal(t, newEmail, fields["new_email"])
}
func TestUpdateUserProfile(t *testing.T) {
t.Parallel()
t.Run("UserNotFound", func(t *testing.T) {
+21
View File
@@ -238,6 +238,13 @@ type UpdateUserProfileRequest struct {
AvatarURL string `json:"avatar_url" format:"uri"`
}
// UpdateUserEmailRequest changes a user's email by matching their current
// email address. This API is experimental and may change without notice.
type UpdateUserEmailRequest struct {
OldEmail string `json:"old_email" validate:"required,email" format:"email"`
NewEmail string `json:"new_email" validate:"required,email" format:"email"`
}
type ValidateUserPasswordRequest struct {
Password string `json:"password" validate:"required"`
}
@@ -608,6 +615,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU
return resp, ReadBodyAsJSON(res, &resp)
}
// UpdateUserEmail changes a user's email address. This API is experimental and
// may change without notice.
func (c *Client) UpdateUserEmail(ctx context.Context, req UpdateUserEmailRequest) error {
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/users/email", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req)
+16
View File
@@ -15876,6 +15876,22 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `model_config_id` | string | false | | |
| `reasoning_effort` | string | false | | |
## codersdk.UpdateUserEmailRequest
```json
{
"new_email": "user@example.com",
"old_email": "user@example.com"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------|--------|----------|--------------|-------------|
| `new_email` | string | true | | |
| `old_email` | string | true | | |
## codersdk.UpdateUserNotificationPreferences
```json
+10
View File
@@ -10032,6 +10032,16 @@ export interface UpdateUserChatPersonalModelOverrideRequest {
readonly reasoning_effort?: string;
}
// From codersdk/users.go
/**
* UpdateUserEmailRequest changes a user's email by matching their current
* email address. This API is experimental and may change without notice.
*/
export interface UpdateUserEmailRequest {
readonly old_email: string;
readonly new_email: string;
}
// From codersdk/notifications.go
export interface UpdateUserNotificationPreferences {
readonly template_disabled_map: Record<string, boolean>;