diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index f2fb37c8b471c..63b949d47a314 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1115,13 +1115,14 @@ func TestNotifications(t *testing.T) { require.NotNil(t, workspace.DormantAt) // Check that a notification was enqueued - require.Len(t, notifyEnq.Sent, 1) - require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID) - require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant) - require.Contains(t, notifyEnq.Sent[0].Targets, template.ID) - require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID) - require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID) - require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID) + require.Len(t, notifyEnq.Sent, 2) + // notifyEnq.Sent[0] is an event for created user account + require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID) + require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant) + require.Contains(t, notifyEnq.Sent[1].Targets, template.ID) + require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID) + require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OrganizationID) + require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OwnerID) }) } diff --git a/coderd/database/migrations/000233_notifications_user_created.down.sql b/coderd/database/migrations/000233_notifications_user_created.down.sql new file mode 100644 index 0000000000000..e54b97d4697f3 --- /dev/null +++ b/coderd/database/migrations/000233_notifications_user_created.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = '4e19c0ac-94e1-4532-9515-d1801aa283b2'; diff --git a/coderd/database/migrations/000233_notifications_user_created.up.sql b/coderd/database/migrations/000233_notifications_user_created.up.sql new file mode 100644 index 0000000000000..4292bfed44986 --- /dev/null +++ b/coderd/database/migrations/000233_notifications_user_created.up.sql @@ -0,0 +1,9 @@ +INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) +VALUES ('4e19c0ac-94e1-4532-9515-d1801aa283b2', 'User account created', E'User account "{{.Labels.created_account_name}}" created', + E'Hi {{.UserName}},\n\New user account **{{.Labels.created_account_name}}** has been created.', + 'Workspace 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 97c5d19f57a19..9908a3e06adfb 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -13,3 +13,8 @@ var ( TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b") TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42") ) + +// Account-related events. +var ( + TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2") +) diff --git a/coderd/users.go b/coderd/users.go index 87ee3d277253b..565aeca1cb2a8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -20,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/gitsshkey" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" @@ -1200,7 +1203,8 @@ func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Reques type CreateUserRequest struct { codersdk.CreateUserRequest - LoginType database.LoginType + LoginType database.LoginType + SkipNotifications bool } 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 } var user database.User - return user, req.OrganizationID, store.InTx(func(tx database.Store) error { + err := store.InTx(func(tx database.Store) error { orgRoles := make([]string, 0) // Organization is required to know where to allocate the user. if req.OrganizationID == uuid.Nil { @@ -1272,6 +1276,37 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create } return nil }, nil) + if err != nil || req.SkipNotifications { + 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}, + }) + 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) + } + + for _, u := range append(owners, userAdmins...) { + if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountCreated, + map[string]string{ + "created_account_name": user.Username, + }, "api-users-create", + user.ID, + ); err != nil { + api.Logger.Warn(ctx, "unable to notify about created user", slog.F("created_user", user.Username), slog.Error(err)) + } + } + return user, req.OrganizationID, err } func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User { diff --git a/coderd/users_test.go b/coderd/users_test.go index 7c19096105a95..82b984226d2b2 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/serpent" @@ -598,6 +599,99 @@ func TestPostUsers(t *testing.T) { }) } +func TestNotifyCreatedUser(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() + + // when + user, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + // then + require.Len(t, notifyEnq.Sent, 1) + require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[0].TemplateID) + require.Equal(t, firstUser.UserID, notifyEnq.Sent[0].UserID) + require.Contains(t, notifyEnq.Sent[0].Targets, user.ID) + require.Equal(t, user.Username, notifyEnq.Sent[0].Labels["created_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, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "user-admin@user.org", + Username: "mr-user-admin", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + _, err = adminClient.UpdateUserRoles(ctx, userAdmin.Username, codersdk.UpdateRoles{ + Roles: []string{ + rbac.RoleUserAdmin().String(), + }, + }) + require.NoError(t, err) + + // when + member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + // then + require.Len(t, notifyEnq.Sent, 3) + + // "User admin" account created, "owner" notified + require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[0].TemplateID) + require.Equal(t, firstUser.UserID, notifyEnq.Sent[0].UserID) + require.Contains(t, notifyEnq.Sent[0].Targets, userAdmin.ID) + require.Equal(t, userAdmin.Username, notifyEnq.Sent[0].Labels["created_account_name"]) + + // "Member" account created, "owner" notified + require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[1].TemplateID) + require.Equal(t, firstUser.UserID, notifyEnq.Sent[1].UserID) + require.Contains(t, notifyEnq.Sent[1].Targets, member.ID) + require.Equal(t, member.Username, notifyEnq.Sent[1].Labels["created_account_name"]) + + // "Member" account created, "user admin" notified + require.Equal(t, notifications.TemplateUserAccountCreated, notifyEnq.Sent[1].TemplateID) + require.Equal(t, userAdmin.ID, notifyEnq.Sent[2].UserID) + require.Contains(t, notifyEnq.Sent[2].Targets, member.ID) + require.Equal(t, member.Username, notifyEnq.Sent[2].Labels["created_account_name"]) + }) +} + func TestUpdateUserProfile(t *testing.T) { t.Parallel() t.Run("UserNotFound", func(t *testing.T) { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 94e89bcd50f98..e809d08b116ea 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3476,13 +3476,14 @@ func TestNotifications(t *testing.T) { // Then require.NoError(t, err, "mark workspace as dormant") - require.Len(t, notifyEnq.Sent, 1) - require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant) - require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID) - require.Contains(t, notifyEnq.Sent[0].Targets, template.ID) - require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID) - require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID) - require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID) + require.Len(t, notifyEnq.Sent, 2) + // notifyEnq.Sent[0] is an event for created user account + require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant) + require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID) + require.Contains(t, notifyEnq.Sent[1].Targets, template.ID) + require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID) + require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OrganizationID) + require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OwnerID) }) t.Run("InitiatorIsOwner", func(t *testing.T) { diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index b7f1bc8d106c4..9a803c51d9589 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -239,6 +239,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { OrganizationID: defaultOrganization.ID, }, LoginType: database.LoginTypeOIDC, + // Do not send notifications to user admins as SCIM endpoint might be called sequentially to all users. + SkipNotifications: true, }) if err != nil { _ = handlerutil.WriteError(rw, err) diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go index 9421c6cf5b785..13b7aa5adca70 100644 --- a/enterprise/coderd/scim_test.go +++ b/enterprise/coderd/scim_test.go @@ -113,10 +113,15 @@ func TestScim(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() + // given scimAPIKey := []byte("hi") mockAudit := audit.NewMock() + notifyEnq := &testutil.FakeNotificationsEnqueuer{} client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{Auditor: mockAudit}, + Options: &coderdtest.Options{ + Auditor: mockAudit, + NotificationsEnqueuer: notifyEnq, + }, SCIMAPIKey: scimAPIKey, AuditLogging: true, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -129,12 +134,15 @@ func TestScim(t *testing.T) { }) mockAudit.ResetLogs() + // when sUser := makeScimUser(t) res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) + // then + // Expect audit logs aLogs := mockAudit.AuditLogs() require.Len(t, aLogs, 1) af := map[string]string{} @@ -143,12 +151,15 @@ func TestScim(t *testing.T) { assert.Equal(t, coderd.SCIMAuditAdditionalFields, af) assert.Equal(t, database.AuditActionCreate, aLogs[0].Action) + // Expect users exposed over API userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) require.NoError(t, err) require.Len(t, userRes.Users, 1) - assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email) assert.Equal(t, sUser.UserName, userRes.Users[0].Username) + + // Expect zero notifications (SkipNotifications = true) + require.Empty(t, notifyEnq.Sent) }) t.Run("Duplicate", func(t *testing.T) {