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)
|
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);
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user