feat: notify about created user account (#14010)

This commit is contained in:
Marcin Tojek
2024-07-30 15:37:45 +02:00
committed by GitHub
parent c6fb779c50
commit cf1fcab514
9 changed files with 177 additions and 18 deletions
+8 -7
View File
@@ -1115,13 +1115,14 @@ func TestNotifications(t *testing.T) {
require.NotNil(t, workspace.DormantAt) require.NotNil(t, workspace.DormantAt)
// Check that a notification was enqueued // Check that a notification was enqueued
require.Len(t, notifyEnq.Sent, 1) require.Len(t, notifyEnq.Sent, 2)
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID) // notifyEnq.Sent[0] is an event for created user account
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant) require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID)
require.Contains(t, notifyEnq.Sent[0].Targets, template.ID) require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID) require.Contains(t, notifyEnq.Sent[1].Targets, template.ID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID) require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID) require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OrganizationID)
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OwnerID)
}) })
} }
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = '4e19c0ac-94e1-4532-9515-d1801aa283b2';
@@ -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);
+5
View File
@@ -13,3 +13,8 @@ var (
TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b") TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b")
TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42") TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42")
) )
// Account-related events.
var (
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
)
+37 -2
View File
@@ -12,6 +12,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk" "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/gitsshkey"
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw" "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"
"github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/searchquery"
@@ -1200,7 +1203,8 @@ func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Reques
type CreateUserRequest struct { type CreateUserRequest struct {
codersdk.CreateUserRequest 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) { 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 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) orgRoles := make([]string, 0)
// Organization is required to know where to allocate the user. // Organization is required to know where to allocate the user.
if req.OrganizationID == uuid.Nil { if req.OrganizationID == uuid.Nil {
@@ -1272,6 +1276,37 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
} }
return nil return nil
}, 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 { func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User {
+94
View File
@@ -10,6 +10,7 @@ import (
"github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest" "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/coder/v2/coderd/rbac/policy"
"github.com/coder/serpent" "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) { func TestUpdateUserProfile(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("UserNotFound", func(t *testing.T) { t.Run("UserNotFound", func(t *testing.T) {
+8 -7
View File
@@ -3476,13 +3476,14 @@ func TestNotifications(t *testing.T) {
// Then // Then
require.NoError(t, err, "mark workspace as dormant") require.NoError(t, err, "mark workspace as dormant")
require.Len(t, notifyEnq.Sent, 1) require.Len(t, notifyEnq.Sent, 2)
require.Equal(t, notifyEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceDormant) // notifyEnq.Sent[0] is an event for created user account
require.Equal(t, notifyEnq.Sent[0].UserID, workspace.OwnerID) require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant)
require.Contains(t, notifyEnq.Sent[0].Targets, template.ID) require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID) require.Contains(t, notifyEnq.Sent[1].Targets, template.ID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID) require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID)
require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID) 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) { t.Run("InitiatorIsOwner", func(t *testing.T) {
+2
View File
@@ -239,6 +239,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
OrganizationID: defaultOrganization.ID, OrganizationID: defaultOrganization.ID,
}, },
LoginType: database.LoginTypeOIDC, 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 { if err != nil {
_ = handlerutil.WriteError(rw, err) _ = handlerutil.WriteError(rw, err)
+13 -2
View File
@@ -113,10 +113,15 @@ func TestScim(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel() defer cancel()
// given
scimAPIKey := []byte("hi") scimAPIKey := []byte("hi")
mockAudit := audit.NewMock() mockAudit := audit.NewMock()
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
client, _ := coderdenttest.New(t, &coderdenttest.Options{ client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{Auditor: mockAudit}, Options: &coderdtest.Options{
Auditor: mockAudit,
NotificationsEnqueuer: notifyEnq,
},
SCIMAPIKey: scimAPIKey, SCIMAPIKey: scimAPIKey,
AuditLogging: true, AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{ LicenseOptions: &coderdenttest.LicenseOptions{
@@ -129,12 +134,15 @@ func TestScim(t *testing.T) {
}) })
mockAudit.ResetLogs() mockAudit.ResetLogs()
// when
sUser := makeScimUser(t) sUser := makeScimUser(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err) require.NoError(t, err)
defer res.Body.Close() defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, http.StatusOK, res.StatusCode)
// then
// Expect audit logs
aLogs := mockAudit.AuditLogs() aLogs := mockAudit.AuditLogs()
require.Len(t, aLogs, 1) require.Len(t, aLogs, 1)
af := map[string]string{} af := map[string]string{}
@@ -143,12 +151,15 @@ func TestScim(t *testing.T) {
assert.Equal(t, coderd.SCIMAuditAdditionalFields, af) assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
assert.Equal(t, database.AuditActionCreate, aLogs[0].Action) 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}) userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, userRes.Users, 1) require.Len(t, userRes.Users, 1)
assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email) assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
assert.Equal(t, sUser.UserName, userRes.Users[0].Username) 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) { t.Run("Duplicate", func(t *testing.T) {