Skip to content

feat: add notification for suspended/activated account #14367

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 22, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DELETE FROM notification_templates WHERE id = 'b02ddd82-4733-4d02-a2d7-c36f3598997d';
DELETE FROM notification_templates WHERE id = '9f5af851-8408-4e73-a7a1-c6502ba46689';
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('b02ddd82-4733-4d02-a2d7-c36f3598997d', 'User account suspended', E'User account "{{.Labels.suspended_account_name}}" suspended',
E'Hi {{.UserName}},\nUser account **{{.Labels.suspended_account_name}}** has been suspended.',
'Workspace Events', '[
{
"label": "View accounts",
"url": "{{ base_url }}/deployment/users?filter=status%3Aactive"
}
]'::jsonb);
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
VALUES ('9f5af851-8408-4e73-a7a1-c6502ba46689', 'User account activated', E'User account "{{.Labels.activated_account_name}}" activated',
E'Hi {{.UserName}},\nUser account **{{.Labels.activated_account_name}}** has been activated.',
'Workspace Events', '[
{
"label": "View accounts",
"url": "{{ base_url }}/deployment/users?filter=status%3Aactive"
}
]'::jsonb);
3 changes: 3 additions & 0 deletions coderd/notifications/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ var (
var (
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6")

TemplateUserAccountSuspended = uuid.MustParse("b02ddd82-4733-4d02-a2d7-c36f3598997d")
TemplateUserAccountActivated = uuid.MustParse("9f5af851-8408-4e73-a7a1-c6502ba46689")
)

// Template-related events.
Expand Down
20 changes: 20 additions & 0 deletions coderd/notifications/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,26 @@ func TestNotificationTemplatesCanRender(t *testing.T) {
},
},
},
{
name: "TemplateUserAccountSuspended",
id: notifications.TemplateUserAccountSuspended,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"suspended_account_name": "bobby",
},
},
},
{
name: "TemplateUserAccountActivated",
id: notifications.TemplateUserAccountActivated,
payload: types.MessagePayload{
UserName: "bobby",
Labels: map[string]string{
"activated_account_name": "bobby",
},
},
},
{
name: "TemplateTemplateDeleted",
id: notifications.TemplateTemplateDeleted,
Expand Down
60 changes: 56 additions & 4 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
}
}

suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
targetUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
ID: user.ID,
Status: status,
UpdatedAt: dbtime.Now(),
Expand All @@ -857,8 +857,61 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
})
return
}
aReq.New = suspendedUser
aReq.New = targetUser

// Notify about the change of user status
var key string
var templateID uuid.UUID
switch status {
case database.UserStatusSuspended:
key = "suspended_account_name"
templateID = notifications.TemplateUserAccountSuspended
case database.UserStatusActive:
key = "activated_account_name"
templateID = notifications.TemplateUserAccountActivated
default:
api.Logger.Error(ctx, "unable to notify admins as the user's status is unsupported", slog.F("username", user.Username), slog.F("user_status", string(status)))

httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error preparing notifications",
})
}

// Fetch all users with user admin permissions
owners, err := api.Database.GetUsers(ctx, database.GetUsersParams{
RbacRole: []string{codersdk.RoleOwner},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching owners",
Detail: err.Error(),
})
return
}
userAdmins, err := api.Database.GetUsers(ctx, database.GetUsersParams{
RbacRole: []string{codersdk.RoleUserAdmin},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user admins",
Detail: err.Error(),
})
return
}

// Send notifications to user admins and affected user
for _, u := range append(append(owners, userAdmins...), database.GetUsersRow{ID: user.ID}) {
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, templateID,
map[string]string{
key: user.Username,
}, "api-put-user-status",
user.ID,
); err != nil {
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
}
}

// Finish: build final response
organizations, err := userOrganizationIDs(ctx, api, user)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Expand All @@ -867,8 +920,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
})
return
}

httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(targetUser, organizations))
}
}

Expand Down
107 changes: 107 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,113 @@ func TestDeleteUser(t *testing.T) {
})
}

func TestNotifyUserSuspended(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.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
require.NoError(t, err)

// then
require.Len(t, notifyEnq.Sent, 3+3) // 3 notifications due to acccount creation + 3 extra due to account suspension
// 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 suspended, "owner" notified
require.Equal(t, notifications.TemplateUserAccountSuspended, 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["suspended_account_name"])

// "Member" account suspended, "user admin" notified
require.Equal(t, notifications.TemplateUserAccountSuspended, 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["suspended_account_name"])

// "Member" account suspended, "member" notified
require.Equal(t, notifications.TemplateUserAccountSuspended, notifyEnq.Sent[5].TemplateID)
require.Equal(t, member.ID, notifyEnq.Sent[5].UserID)
require.Contains(t, notifyEnq.Sent[5].Targets, member.ID)
require.Equal(t, member.Username, notifyEnq.Sent[5].Labels["suspended_account_name"])
}

func TestNotifyUserReactivate(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)

_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
require.NoError(t, err)

// when
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusActive)
require.NoError(t, err)

// then
require.Len(t, notifyEnq.Sent, 6+3) // 6 notifications due to account suspension + 3 extra due to activation
// 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 suspended, "owner" notified
require.Equal(t, notifications.TemplateUserAccountActivated, notifyEnq.Sent[6].TemplateID)
require.Equal(t, firstUser.UserID, notifyEnq.Sent[6].UserID)
require.Contains(t, notifyEnq.Sent[6].Targets, member.ID)
require.Equal(t, member.Username, notifyEnq.Sent[6].Labels["activated_account_name"])

// "Member" account suspended, "user admin" notified
require.Equal(t, notifications.TemplateUserAccountActivated, notifyEnq.Sent[7].TemplateID)
require.Equal(t, userAdmin.ID, notifyEnq.Sent[7].UserID)
require.Contains(t, notifyEnq.Sent[7].Targets, member.ID)
require.Equal(t, member.Username, notifyEnq.Sent[7].Labels["activated_account_name"])

// "Member" account suspended, "member" notified
require.Equal(t, notifications.TemplateUserAccountActivated, notifyEnq.Sent[8].TemplateID)
require.Equal(t, member.ID, notifyEnq.Sent[8].UserID)
require.Contains(t, notifyEnq.Sent[8].Targets, member.ID)
require.Equal(t, member.Username, notifyEnq.Sent[8].Labels["activated_account_name"])
}

func TestNotifyDeletedUser(t *testing.T) {
t.Parallel()

Expand Down
Loading