diff --git a/cli/exp_update_user_email.go b/cli/exp_update_user_email.go new file mode 100644 index 00000000000..87de3223ae5 --- /dev/null +++ b/cli/exp_update_user_email.go @@ -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 +} diff --git a/cli/exp_update_user_email_test.go b/cli/exp_update_user_email_test.go new file mode 100644 index 00000000000..01ff4d00f18 --- /dev/null +++ b/cli/exp_update_user_email_test.go @@ -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") + }) +} diff --git a/cli/root.go b/cli/root.go index 23055d93ee6..6fc7511b236 100644 --- a/cli/root.go +++ b/cli/root.go @@ -171,6 +171,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command { r.promptExample(), r.rptyCommand(), r.syncCommand(), + r.updateUserEmail(), } } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a576e285eca..1b1f1964068 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 06ed66cfc0a..5def2ccd708 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 1b391185eb3..c346f33646f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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( diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b6fc628ca34..9e0bc143272 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4f1b3c0e433..9cea5e8d4f9 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 01fdeda2234..96fdffeb3f3 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8d3d8add3d1..622f9dc33a1 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ed6dfc1c789..e63f9826f7b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b630a4278a2..23a9c0c2f36 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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) + }) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 36b4696588c..fd710b3194a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 3c4875d766d..e04d54e0c87 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -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 diff --git a/coderd/users.go b/coderd/users.go index 40df48ef184..c97c541a13a 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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 diff --git a/coderd/users_test.go b/coderd/users_test.go index 9c781850d5b..a9bf388bc0d 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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) { diff --git a/codersdk/users.go b/codersdk/users.go index c784c13e8c9..ab0d9edfa25 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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) diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 846a5e4853b..022d88fc140 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 01b880e234a..48f92bb67f9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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;