diff --git a/coderd/database/migrations/000236_notifications_user_deleted.down.sql b/coderd/database/migrations/000236_notifications_user_deleted.down.sql new file mode 100644 index 0000000000000..e0d3c2f7e9823 --- /dev/null +++ b/coderd/database/migrations/000236_notifications_user_deleted.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = 'f44d9314-ad03-4bc8-95d0-5cad491da6b6'; diff --git a/coderd/database/migrations/000236_notifications_user_deleted.up.sql b/coderd/database/migrations/000236_notifications_user_deleted.up.sql new file mode 100644 index 0000000000000..d8354ca2b4c5d --- /dev/null +++ b/coderd/database/migrations/000236_notifications_user_deleted.up.sql @@ -0,0 +1,9 @@ +INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) +VALUES ('f44d9314-ad03-4bc8-95d0-5cad491da6b6', 'User account deleted', E'User account "{{.Labels.deleted_account_name}}" deleted', + E'Hi {{.UserName}},\n\nUser account **{{.Labels.deleted_account_name}}** has been deleted.', + 'User Events', '[ + { + "label": "View accounts", + "url": "{{ base_url }}/deployment/users?filter=status%3Aactive" + } + ]'::jsonb); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 9908a3e06adfb..c00912d70734c 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -17,4 +17,5 @@ var ( // Account-related events. var ( TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2") + TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6") ) diff --git a/coderd/users.go b/coderd/users.go index bfa81f86624e4..adf329ea0059d 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -567,6 +567,27 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { } user.Deleted = true aReq.New = user + + userAdmins, err := findUserAdmins(ctx, api.Database) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user admins.", + Detail: err.Error(), + }) + return + } + + for _, u := range userAdmins { + if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountDeleted, + map[string]string{ + "deleted_account_name": user.Username, + }, "api-users-delete", + user.ID, + ); err != nil { + api.Logger.Warn(ctx, "unable to notify about deleted user", slog.F("deleted_user", user.Username), slog.Error(err)) + } + } + rw.WriteHeader(http.StatusNoContent) } @@ -1287,23 +1308,12 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return user, req.OrganizationID, err } - // Notify all users with user admin permission including owners - // Notice: we can't scrape the user information in parallel as pq - // fails with: unexpected describe rows response: 'D' - owners, err := store.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleOwner}, - }) + userAdmins, err := findUserAdmins(ctx, store) if err != nil { - return user, req.OrganizationID, xerrors.Errorf("get owners: %w", err) - } - userAdmins, err := store.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleUserAdmin}, - }) - if err != nil { - return user, req.OrganizationID, xerrors.Errorf("get user admins: %w", err) + return user, req.OrganizationID, xerrors.Errorf("find user admins: %w", err) } - for _, u := range append(owners, userAdmins...) { + for _, u := range userAdmins { if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountCreated, map[string]string{ "created_account_name": user.Username, @@ -1316,6 +1326,25 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return user, req.OrganizationID, err } +// findUserAdmins fetches all users with user admin permission including owners. +func findUserAdmins(ctx context.Context, store database.Store) ([]database.GetUsersRow, error) { + // Notice: we can't scrape the user information in parallel as pq + // fails with: unexpected describe rows response: 'D' + owners, err := store.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleOwner}, + }) + if err != nil { + return nil, xerrors.Errorf("get owners: %w", err) + } + userAdmins, err := store.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleUserAdmin}, + }) + if err != nil { + return nil, xerrors.Errorf("get user admins: %w", err) + } + return append(owners, userAdmins...), nil +} + func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User { converted := make([]codersdk.User, 0, len(users)) for _, u := range users { diff --git a/coderd/users_test.go b/coderd/users_test.go index d84dfee820b90..4f44da42ed59b 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -374,6 +374,90 @@ func TestDeleteUser(t *testing.T) { }) } +func TestNotifyDeletedUser(t *testing.T) { + t.Parallel() + + t.Run("OwnerNotified", func(t *testing.T) { + t.Parallel() + + // given + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + adminClient := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + // when + err = adminClient.DeleteUser(context.Background(), user.ID) + require.NoError(t, err) + + // then + require.Len(t, notifyEnq.Sent, 2) + // notifyEnq.Sent[0] is create account event + require.Equal(t, notifications.TemplateUserAccountDeleted, notifyEnq.Sent[1].TemplateID) + require.Equal(t, firstUser.UserID, notifyEnq.Sent[1].UserID) + require.Contains(t, notifyEnq.Sent[1].Targets, user.ID) + require.Equal(t, user.Username, notifyEnq.Sent[1].Labels["deleted_account_name"]) + }) + + t.Run("UserAdminNotified", func(t *testing.T) { + t.Parallel() + + // given + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + adminClient := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin()) + + member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + // when + err = adminClient.DeleteUser(context.Background(), member.ID) + require.NoError(t, err) + + // then + require.Len(t, notifyEnq.Sent, 5) + // notifyEnq.Sent[0]: "User admin" account created, "owner" notified + // notifyEnq.Sent[1]: "Member" account created, "owner" notified + // notifyEnq.Sent[2]: "Member" account created, "user admin" notified + + // "Member" account deleted, "owner" notified + require.Equal(t, notifications.TemplateUserAccountDeleted, notifyEnq.Sent[3].TemplateID) + require.Equal(t, firstUser.UserID, notifyEnq.Sent[3].UserID) + require.Contains(t, notifyEnq.Sent[3].Targets, member.ID) + require.Equal(t, member.Username, notifyEnq.Sent[3].Labels["deleted_account_name"]) + + // "Member" account deleted, "user admin" notified + require.Equal(t, notifications.TemplateUserAccountDeleted, notifyEnq.Sent[4].TemplateID) + require.Equal(t, userAdmin.ID, notifyEnq.Sent[4].UserID) + require.Contains(t, notifyEnq.Sent[4].Targets, member.ID) + require.Equal(t, member.Username, notifyEnq.Sent[4].Labels["deleted_account_name"]) + }) +} + func TestPostLogout(t *testing.T) { t.Parallel()