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() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy