mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: notify about created user account (#14010)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
+37
-2
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user