Skip to content

Commit cf1fcab

Browse files
authored
feat: notify about created user account (#14010)
1 parent c6fb779 commit cf1fcab

File tree

9 files changed

+177
-18
lines changed

9 files changed

+177
-18
lines changed

coderd/autobuild/lifecycle_executor_test.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,13 +1115,14 @@ func TestNotifications(t *testing.T) {
11151115
require.NotNil(t, workspace.DormantAt)
11161116

11171117
// Check that a notification was enqueued
1118-
require.Len(t, notifyEnq.Sent, 1)
1119-
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID)
1120-
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
1121-
require.Contains(t, notifyEnq.Sent[0].Targets, template.ID)
1122-
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID)
1123-
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID)
1124-
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID)
1118+
require.Len(t, notifyEnq.Sent, 2)
1119+
// notifyEnq.Sent[0] is an event for created user account
1120+
require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID)
1121+
require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant)
1122+
require.Contains(t, notifyEnq.Sent[1].Targets, template.ID)
1123+
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID)
1124+
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OrganizationID)
1125+
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OwnerID)
11251126
})
11261127
}
11271128

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DELETE FROM notification_templates WHERE id = '4e19c0ac-94e1-4532-9515-d1801aa283b2';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
2+
VALUES ('4e19c0ac-94e1-4532-9515-d1801aa283b2', 'User account created', E'User account "{{.Labels.created_account_name}}" created',
3+
E'Hi {{.UserName}},\n\New user account **{{.Labels.created_account_name}}** has been created.',
4+
'Workspace Events', '[
5+
{
6+
"label": "View accounts",
7+
"url": "{{ base_url }}/deployment/users?filter=status%3Aactive"
8+
}
9+
]'::jsonb);

coderd/notifications/events.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ var (
1313
TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b")
1414
TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42")
1515
)
16+
17+
// Account-related events.
18+
var (
19+
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
20+
)

coderd/users.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"github.com/google/uuid"
1313
"golang.org/x/xerrors"
1414

15+
"cdr.dev/slog"
16+
1517
"github.com/coder/coder/v2/coderd/audit"
1618
"github.com/coder/coder/v2/coderd/database"
1719
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -20,6 +22,7 @@ import (
2022
"github.com/coder/coder/v2/coderd/gitsshkey"
2123
"github.com/coder/coder/v2/coderd/httpapi"
2224
"github.com/coder/coder/v2/coderd/httpmw"
25+
"github.com/coder/coder/v2/coderd/notifications"
2326
"github.com/coder/coder/v2/coderd/rbac"
2427
"github.com/coder/coder/v2/coderd/rbac/policy"
2528
"github.com/coder/coder/v2/coderd/searchquery"
@@ -1200,7 +1203,8 @@ func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Reques
12001203

12011204
type CreateUserRequest struct {
12021205
codersdk.CreateUserRequest
1203-
LoginType database.LoginType
1206+
LoginType database.LoginType
1207+
SkipNotifications bool
12041208
}
12051209

12061210
func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, uuid.UUID, error) {
@@ -1211,7 +1215,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
12111215
}
12121216

12131217
var user database.User
1214-
return user, req.OrganizationID, store.InTx(func(tx database.Store) error {
1218+
err := store.InTx(func(tx database.Store) error {
12151219
orgRoles := make([]string, 0)
12161220
// Organization is required to know where to allocate the user.
12171221
if req.OrganizationID == uuid.Nil {
@@ -1272,6 +1276,37 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
12721276
}
12731277
return nil
12741278
}, nil)
1279+
if err != nil || req.SkipNotifications {
1280+
return user, req.OrganizationID, err
1281+
}
1282+
1283+
// Notify all users with user admin permission including owners
1284+
// Notice: we can't scrape the user information in parallel as pq
1285+
// fails with: unexpected describe rows response: 'D'
1286+
owners, err := store.GetUsers(ctx, database.GetUsersParams{
1287+
RbacRole: []string{codersdk.RoleOwner},
1288+
})
1289+
if err != nil {
1290+
return user, req.OrganizationID, xerrors.Errorf("get owners: %w", err)
1291+
}
1292+
userAdmins, err := store.GetUsers(ctx, database.GetUsersParams{
1293+
RbacRole: []string{codersdk.RoleUserAdmin},
1294+
})
1295+
if err != nil {
1296+
return user, req.OrganizationID, xerrors.Errorf("get user admins: %w", err)
1297+
}
1298+
1299+
for _, u := range append(owners, userAdmins...) {
1300+
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountCreated,
1301+
map[string]string{
1302+
"created_account_name": user.Username,
1303+
}, "api-users-create",
1304+
user.ID,
1305+
); err != nil {
1306+
api.Logger.Warn(ctx, "unable to notify about created user", slog.F("created_user", user.Username), slog.Error(err))
1307+
}
1308+
}
1309+
return user, req.OrganizationID, err
12751310
}
12761311

12771312
func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User {

coderd/users_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/coder/coder/v2/coderd"
1212
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
13+
"github.com/coder/coder/v2/coderd/notifications"
1314
"github.com/coder/coder/v2/coderd/rbac/policy"
1415
"github.com/coder/serpent"
1516

@@ -598,6 +599,99 @@ func TestPostUsers(t *testing.T) {
598599
})
599600
}
600601

602+
func TestNotifyCreatedUser(t *testing.T) {
603+
t.Parallel()
604+
605+
t.Run("OwnerNotified", func(t *testing.T) {
606+
t.Parallel()
607+
608+
// given
609+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
610+
adminClient := coderdtest.New(t, &coderdtest.Options{
611+
NotificationsEnqueuer: notifyEnq,
612+
})
613+
firstUser := coderdtest.CreateFirstUser(t, adminClient)
614+
615+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
616+
defer cancel()
617+
618+
// when
619+
user, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
620+
OrganizationID: firstUser.OrganizationID,
621+
Email: "another@user.org",
622+
Username: "someone-else",
623+
Password: "SomeSecurePassword!",
624+
})
625+
require.NoError(t, err)
626+
627+
// then
628+
require.Len(t, notifyEnq.Sent, 1)
629+
require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[0].TemplateID)
630+
require.Equal(t, firstUser.UserID, notifyEnq.Sent[0].UserID)
631+
require.Contains(t, notifyEnq.Sent[0].Targets, user.ID)
632+
require.Equal(t, user.Username, notifyEnq.Sent[0].Labels["created_account_name"])
633+
})
634+
635+
t.Run("UserAdminNotified", func(t *testing.T) {
636+
t.Parallel()
637+
638+
// given
639+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
640+
adminClient := coderdtest.New(t, &coderdtest.Options{
641+
NotificationsEnqueuer: notifyEnq,
642+
})
643+
firstUser := coderdtest.CreateFirstUser(t, adminClient)
644+
645+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
646+
defer cancel()
647+
648+
userAdmin, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
649+
OrganizationID: firstUser.OrganizationID,
650+
Email: "user-admin@user.org",
651+
Username: "mr-user-admin",
652+
Password: "SomeSecurePassword!",
653+
})
654+
require.NoError(t, err)
655+
656+
_, err = adminClient.UpdateUserRoles(ctx, userAdmin.Username, codersdk.UpdateRoles{
657+
Roles: []string{
658+
rbac.RoleUserAdmin().String(),
659+
},
660+
})
661+
require.NoError(t, err)
662+
663+
// when
664+
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
665+
OrganizationID: firstUser.OrganizationID,
666+
Email: "another@user.org",
667+
Username: "someone-else",
668+
Password: "SomeSecurePassword!",
669+
})
670+
require.NoError(t, err)
671+
672+
// then
673+
require.Len(t, notifyEnq.Sent, 3)
674+
675+
// "User admin" account created, "owner" notified
676+
require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[0].TemplateID)
677+
require.Equal(t, firstUser.UserID, notifyEnq.Sent[0].UserID)
678+
require.Contains(t, notifyEnq.Sent[0].Targets, userAdmin.ID)
679+
require.Equal(t, userAdmin.Username, notifyEnq.Sent[0].Labels["created_account_name"])
680+
681+
// "Member" account created, "owner" notified
682+
require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[1].TemplateID)
683+
require.Equal(t, firstUser.UserID, notifyEnq.Sent[1].UserID)
684+
require.Contains(t, notifyEnq.Sent[1].Targets, member.ID)
685+
require.Equal(t, member.Username, notifyEnq.Sent[1].Labels["created_account_name"])
686+
687+
// "Member" account created, "user admin" notified
688+
require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[1].TemplateID)
689+
require.Equal(t, userAdmin.ID, notifyEnq.Sent[2].UserID)
690+
require.Contains(t, notifyEnq.Sent[2].Targets, member.ID)
691+
require.Equal(t, member.Username, notifyEnq.Sent[2].Labels["created_account_name"])
692+
})
693+
}
694+
601695
func TestUpdateUserProfile(t *testing.T) {
602696
t.Parallel()
603697
t.Run("UserNotFound", func(t *testing.T) {

coderd/workspaces_test.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3476,13 +3476,14 @@ func TestNotifications(t *testing.T) {
34763476

34773477
// Then
34783478
require.NoError(t, err, "mark workspace as dormant")
3479-
require.Len(t, notifyEnq.Sent, 1)
3480-
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
3481-
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID)
3482-
require.Contains(t, notifyEnq.Sent[0].Targets, template.ID)
3483-
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID)
3484-
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID)
3485-
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID)
3479+
require.Len(t, notifyEnq.Sent, 2)
3480+
// notifyEnq.Sent[0] is an event for created user account
3481+
require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant)
3482+
require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID)
3483+
require.Contains(t, notifyEnq.Sent[1].Targets, template.ID)
3484+
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID)
3485+
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OrganizationID)
3486+
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OwnerID)
34863487
})
34873488

34883489
t.Run("InitiatorIsOwner", func(t *testing.T) {

enterprise/coderd/scim.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
239239
OrganizationID: defaultOrganization.ID,
240240
},
241241
LoginType: database.LoginTypeOIDC,
242+
// Do not send notifications to user admins as SCIM endpoint might be called sequentially to all users.
243+
SkipNotifications: true,
242244
})
243245
if err != nil {
244246
_ = handlerutil.WriteError(rw, err)

enterprise/coderd/scim_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,15 @@ func TestScim(t *testing.T) {
113113
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
114114
defer cancel()
115115

116+
// given
116117
scimAPIKey := []byte("hi")
117118
mockAudit := audit.NewMock()
119+
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
118120
client, _ := coderdenttest.New(t, &coderdenttest.Options{
119-
Options: &coderdtest.Options{Auditor: mockAudit},
121+
Options: &coderdtest.Options{
122+
Auditor: mockAudit,
123+
NotificationsEnqueuer: notifyEnq,
124+
},
120125
SCIMAPIKey: scimAPIKey,
121126
AuditLogging: true,
122127
LicenseOptions: &coderdenttest.LicenseOptions{
@@ -129,12 +134,15 @@ func TestScim(t *testing.T) {
129134
})
130135
mockAudit.ResetLogs()
131136

137+
// when
132138
sUser := makeScimUser(t)
133139
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
134140
require.NoError(t, err)
135141
defer res.Body.Close()
136142
require.Equal(t, http.StatusOK, res.StatusCode)
137143

144+
// then
145+
// Expect audit logs
138146
aLogs := mockAudit.AuditLogs()
139147
require.Len(t, aLogs, 1)
140148
af := map[string]string{}
@@ -143,12 +151,15 @@ func TestScim(t *testing.T) {
143151
assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
144152
assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
145153

154+
// Expect users exposed over API
146155
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
147156
require.NoError(t, err)
148157
require.Len(t, userRes.Users, 1)
149-
150158
assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
151159
assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
160+
161+
// Expect zero notifications (SkipNotifications = true)
162+
require.Empty(t, notifyEnq.Sent)
152163
})
153164

154165
t.Run("Duplicate", func(t *testing.T) {

0 commit comments

Comments
 (0)
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