From c18ada0481734dc74398f100aac2cc25134377b2 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 25 Jul 2024 10:17:50 +0200 Subject: [PATCH 01/23] feat: notify about created user account --- coderd/users.go | 9 +++++++-- enterprise/coderd/scim.go | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index bf06bba69498f..9d404ad718ac7 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1205,7 +1205,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) { @@ -1216,7 +1217,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 { @@ -1277,6 +1278,10 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create } return nil }, nil) + if err == nil { + // TODO: Notify user admins + } + return user, req.OrganizationID, err } func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User { 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) From ca2bdde975f1bc3f1d2cc8f1f9b87b30ef59c4ec Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 26 Jul 2024 10:59:40 +0200 Subject: [PATCH 02/23] migrations --- .../000232_notifications_user_created.down.sql | 1 + .../migrations/000232_notifications_user_created.up.sql | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 coderd/database/migrations/000232_notifications_user_created.down.sql create mode 100644 coderd/database/migrations/000232_notifications_user_created.up.sql diff --git a/coderd/database/migrations/000232_notifications_user_created.down.sql b/coderd/database/migrations/000232_notifications_user_created.down.sql new file mode 100644 index 0000000000000..e54b97d4697f3 --- /dev/null +++ b/coderd/database/migrations/000232_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/000232_notifications_user_created.up.sql b/coderd/database/migrations/000232_notifications_user_created.up.sql new file mode 100644 index 0000000000000..41743c6a69f98 --- /dev/null +++ b/coderd/database/migrations/000232_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.user_account_name}}" created', + E'Hi {{.UserName}}\n\New user account **{{.Labels.user_account_name}}** has been created.', + 'Workspace Events', '[ + { + "label": "View accounts", + "url": "{{ base_url }}/deployment/users?filter=status%3Aactive" + } + ]'::jsonb); From d6e8964d229e663e5b441ae2a0f8b54e0387b8c1 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 26 Jul 2024 11:01:11 +0200 Subject: [PATCH 03/23] Username --- .../migrations/000232_notifications_user_created.up.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/000232_notifications_user_created.up.sql b/coderd/database/migrations/000232_notifications_user_created.up.sql index 41743c6a69f98..771b3566a2dfb 100644 --- a/coderd/database/migrations/000232_notifications_user_created.up.sql +++ b/coderd/database/migrations/000232_notifications_user_created.up.sql @@ -1,9 +1,10 @@ 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.user_account_name}}" created', - E'Hi {{.UserName}}\n\New user account **{{.Labels.user_account_name}}** has been created.', + E'Hi {{.UserName}},\n\New user account **{{.Labels.user_account_name}}** has been created.', 'Workspace Events', '[ { "label": "View accounts", "url": "{{ base_url }}/deployment/users?filter=status%3Aactive" } ]'::jsonb); +v From 2d74c4e5b59d463b87617025540d966e3cb5074e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 26 Jul 2024 11:02:25 +0200 Subject: [PATCH 04/23] events --- coderd/notifications/events.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 97c5d19f57a19..c55043368fab1 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -12,4 +12,6 @@ var ( TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0") TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b") TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42") + + TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2") ) From 3c3b5afa8251966a855e81af1aa0cac15b2f4de4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 26 Jul 2024 11:09:35 +0200 Subject: [PATCH 05/23] fix --- .../database/migrations/000232_notifications_user_created.up.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/database/migrations/000232_notifications_user_created.up.sql b/coderd/database/migrations/000232_notifications_user_created.up.sql index 771b3566a2dfb..1ee369e5a6c47 100644 --- a/coderd/database/migrations/000232_notifications_user_created.up.sql +++ b/coderd/database/migrations/000232_notifications_user_created.up.sql @@ -7,4 +7,3 @@ VALUES ('4e19c0ac-94e1-4532-9515-d1801aa283b2', 'User account created', E'User a "url": "{{ base_url }}/deployment/users?filter=status%3Aactive" } ]'::jsonb); -v From 312d9fa212710e0a0613ce6a9bb31a9368039e85 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 26 Jul 2024 15:21:10 +0200 Subject: [PATCH 06/23] WIP --- coderd/database/dbauthz/dbauthz.go | 4 +++ coderd/database/dbmem/dbmem.go | 4 +++ coderd/database/dbmetrics/dbmetrics.go | 7 ++++ coderd/database/dbmock/dbmock.go | 15 +++++++++ coderd/database/models.go | 2 +- coderd/database/querier.go | 5 ++- coderd/database/queries.sql.go | 44 +++++++++++++++++++++++++- coderd/database/queries/users.sql | 13 ++++++++ coderd/users.go | 3 ++ 9 files changed, 94 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d12b9aba23863..4b61e490e6b58 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2131,6 +2131,10 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas return q.db.GetUsersByIDs(ctx, ids) } +func (q *querier) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { + panic("not implemented") +} + func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8d1088616f6bc..b697d6e8bf173 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5153,6 +5153,10 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } +func (q *FakeQuerier) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { + panic("not implemented") +} + func (q *FakeQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(_ context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f987d0505653b..269cbb28263f6 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1243,6 +1243,13 @@ func (m metricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]dat return users, err } +func (m metricsStore) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { + start := time.Now() + r0, r1 := m.s.GetUsersWithUserAdminPrivileges(ctx) + m.queryLatencies.WithLabelValues("GetUsersWithUserAdminPrivileges").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 78cd95a69cde5..a0dcfc7031843 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2575,6 +2575,21 @@ func (mr *MockStoreMockRecorder) GetUsersByIDs(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByIDs", reflect.TypeOf((*MockStore)(nil).GetUsersByIDs), arg0, arg1) } +// GetUsersWithUserAdminPrivileges mocks base method. +func (m *MockStore) GetUsersWithUserAdminPrivileges(arg0 context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersWithUserAdminPrivileges", arg0) + ret0, _ := ret[0].([]database.GetUsersWithUserAdminPrivilegesRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsersWithUserAdminPrivileges indicates an expected call of GetUsersWithUserAdminPrivileges. +func (mr *MockStoreMockRecorder) GetUsersWithUserAdminPrivileges(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersWithUserAdminPrivileges", reflect.TypeOf((*MockStore)(nil).GetUsersWithUserAdminPrivileges), arg0) +} + // GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method. func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(arg0 context.Context, arg1 uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/models.go b/coderd/database/models.go index 0ee78e286516e..9cae6c32833f0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9d0494813e306..aad282f3dcecc 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database @@ -272,6 +272,9 @@ type sqlcQuerier interface { // to look up references to actions. eg. a user could build a workspace // for another user, then be deleted... we still want them to appear! GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) + // GetUsersWithUserAdminPrivileges returns users who can perform operations + // on user accounts (create/delete/suspend/etc.). + GetUsersWithUserAdminPrivileges(ctx context.Context) ([]GetUsersWithUserAdminPrivilegesRow, error) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2e3a5c9892d40..44c62220c75f7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database @@ -9521,6 +9521,48 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User return items, nil } +const getUsersWithUserAdminPrivileges = `-- name: GetUsersWithUserAdminPrivileges :many +SELECT id, username +FROM users +WHERE + deleted = 'f' + AND ( + 'owner' = ANY(rbac_roles) + OR rbac_roles @> ARRAY['user-admin'] + ) +ORDER BY username ASC +` + +type GetUsersWithUserAdminPrivilegesRow struct { + ID uuid.UUID `db:"id" json:"id"` + Username string `db:"username" json:"username"` +} + +// GetUsersWithUserAdminPrivileges returns users who can perform operations +// on user accounts (create/delete/suspend/etc.). +func (q *sqlQuerier) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]GetUsersWithUserAdminPrivilegesRow, error) { + rows, err := q.db.QueryContext(ctx, getUsersWithUserAdminPrivileges) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersWithUserAdminPrivilegesRow + for rows.Next() { + var i GetUsersWithUserAdminPrivilegesRow + if err := rows.Scan(&i.ID, &i.Username); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertUser = `-- name: InsertUser :one INSERT INTO users ( diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 6bbfdac112d7a..5c7f17de1a3ef 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -281,3 +281,16 @@ RETURNING id, email, last_seen_at; -- AllUserIDs returns all UserIDs regardless of user status or deletion. -- name: AllUserIDs :many SELECT DISTINCT id FROM USERS; + +-- name: GetUsersWithUserAdminPrivileges :many +-- GetUsersWithUserAdminPrivileges returns users who can perform operations +-- on user accounts (create/delete/suspend/etc.). +SELECT id, username +FROM users +WHERE + deleted = 'f' + AND ( + 'owner' = ANY(rbac_roles) + OR rbac_roles @> ARRAY['user-admin'] + ) +ORDER BY username ASC; diff --git a/coderd/users.go b/coderd/users.go index 8ee1cb44c24ae..90b26ec238554 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1275,6 +1275,9 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create }, nil) if err == nil { // TODO: Notify user admins + + // Get N users with user admin permission + // Enqueue N notifications } return user, req.OrganizationID, err } From 82ec37f4b4e814416a3896cb88c8674bd01f90f6 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 26 Jul 2024 15:55:06 +0200 Subject: [PATCH 07/23] fix version --- coderd/database/models.go | 2 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/database/models.go b/coderd/database/models.go index 9cae6c32833f0..0ee78e286516e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index aad282f3dcecc..2a23a5545963c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 44c62220c75f7..c53c0aa1bf42b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database From d7b2c73344d867ce7b7235b06dffdb4677ce8091 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 14:14:13 +0200 Subject: [PATCH 08/23] simplify --- coderd/database/dbauthz/dbauthz.go | 4 --- coderd/database/dbmem/dbmem.go | 4 --- coderd/database/dbmetrics/dbmetrics.go | 7 ---- coderd/database/dbmock/dbmock.go | 15 --------- coderd/database/models.go | 2 +- coderd/database/querier.go | 5 +-- coderd/database/queries.sql.go | 44 +------------------------- coderd/database/queries/users.sql | 13 -------- coderd/users.go | 29 +++++++++++++++-- 9 files changed, 30 insertions(+), 93 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4b61e490e6b58..d12b9aba23863 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2131,10 +2131,6 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas return q.db.GetUsersByIDs(ctx, ids) } -func (q *querier) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { - panic("not implemented") -} - func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index b697d6e8bf173..8d1088616f6bc 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5153,10 +5153,6 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } -func (q *FakeQuerier) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { - panic("not implemented") -} - func (q *FakeQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(_ context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 269cbb28263f6..f987d0505653b 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1243,13 +1243,6 @@ func (m metricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]dat return users, err } -func (m metricsStore) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { - start := time.Now() - r0, r1 := m.s.GetUsersWithUserAdminPrivileges(ctx) - m.queryLatencies.WithLabelValues("GetUsersWithUserAdminPrivileges").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m metricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index a0dcfc7031843..78cd95a69cde5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2575,21 +2575,6 @@ func (mr *MockStoreMockRecorder) GetUsersByIDs(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByIDs", reflect.TypeOf((*MockStore)(nil).GetUsersByIDs), arg0, arg1) } -// GetUsersWithUserAdminPrivileges mocks base method. -func (m *MockStore) GetUsersWithUserAdminPrivileges(arg0 context.Context) ([]database.GetUsersWithUserAdminPrivilegesRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUsersWithUserAdminPrivileges", arg0) - ret0, _ := ret[0].([]database.GetUsersWithUserAdminPrivilegesRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetUsersWithUserAdminPrivileges indicates an expected call of GetUsersWithUserAdminPrivileges. -func (mr *MockStoreMockRecorder) GetUsersWithUserAdminPrivileges(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersWithUserAdminPrivileges", reflect.TypeOf((*MockStore)(nil).GetUsersWithUserAdminPrivileges), arg0) -} - // GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method. func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(arg0 context.Context, arg1 uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/models.go b/coderd/database/models.go index 0ee78e286516e..9cae6c32833f0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2a23a5545963c..ccdcd101255dc 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database @@ -272,9 +272,6 @@ type sqlcQuerier interface { // to look up references to actions. eg. a user could build a workspace // for another user, then be deleted... we still want them to appear! GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) - // GetUsersWithUserAdminPrivileges returns users who can perform operations - // on user accounts (create/delete/suspend/etc.). - GetUsersWithUserAdminPrivileges(ctx context.Context) ([]GetUsersWithUserAdminPrivilegesRow, error) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c53c0aa1bf42b..d4dd192eeb350 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.25.0 +// sqlc v1.26.0 package database @@ -9521,48 +9521,6 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User return items, nil } -const getUsersWithUserAdminPrivileges = `-- name: GetUsersWithUserAdminPrivileges :many -SELECT id, username -FROM users -WHERE - deleted = 'f' - AND ( - 'owner' = ANY(rbac_roles) - OR rbac_roles @> ARRAY['user-admin'] - ) -ORDER BY username ASC -` - -type GetUsersWithUserAdminPrivilegesRow struct { - ID uuid.UUID `db:"id" json:"id"` - Username string `db:"username" json:"username"` -} - -// GetUsersWithUserAdminPrivileges returns users who can perform operations -// on user accounts (create/delete/suspend/etc.). -func (q *sqlQuerier) GetUsersWithUserAdminPrivileges(ctx context.Context) ([]GetUsersWithUserAdminPrivilegesRow, error) { - rows, err := q.db.QueryContext(ctx, getUsersWithUserAdminPrivileges) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetUsersWithUserAdminPrivilegesRow - for rows.Next() { - var i GetUsersWithUserAdminPrivilegesRow - if err := rows.Scan(&i.ID, &i.Username); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const insertUser = `-- name: InsertUser :one INSERT INTO users ( diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 5c7f17de1a3ef..6bbfdac112d7a 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -281,16 +281,3 @@ RETURNING id, email, last_seen_at; -- AllUserIDs returns all UserIDs regardless of user status or deletion. -- name: AllUserIDs :many SELECT DISTINCT id FROM USERS; - --- name: GetUsersWithUserAdminPrivileges :many --- GetUsersWithUserAdminPrivileges returns users who can perform operations --- on user accounts (create/delete/suspend/etc.). -SELECT id, username -FROM users -WHERE - deleted = 'f' - AND ( - 'owner' = ANY(rbac_roles) - OR rbac_roles @> ARRAY['user-admin'] - ) -ORDER BY username ASC; diff --git a/coderd/users.go b/coderd/users.go index 90b26ec238554..5f95d2f0b3df2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/audit" @@ -1274,9 +1275,33 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return nil }, nil) if err == nil { - // TODO: Notify user admins + // Notify user admins + // Get all users with user admin permission including owners + var owners, users []database.User + var eg errgroup.Group + eg.Go(func() error { + owners, err := api.Database.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleOwner}, + }) + if err != nil { + return xerrors.Errorf("get owner: %w", err) + } + return nil + }) + eg.Go(func() error { + userAdmin, err := api.Database.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleOrganizationUserAdmin}, + }) + if err != nil { + return xerrors.Errorf("get user admins: %w", err) + } + return nil + }) + err := eg.Wait() + if err != nil { + return database.User{}, uuid.Nil, err + } - // Get N users with user admin permission // Enqueue N notifications } return user, req.OrganizationID, err From b81bb6a2af5040d172c246af473d83133fb883a4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 14:54:07 +0200 Subject: [PATCH 09/23] WIP --- coderd/users.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 5f95d2f0b3df2..a50436c171921 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -13,6 +13,8 @@ import ( "golang.org/x/sync/errgroup" "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" @@ -21,6 +23,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" @@ -1277,19 +1280,21 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create if err == nil { // Notify user admins // Get all users with user admin permission including owners - var owners, users []database.User + var owners, userAdmins []database.GetUsersRow var eg errgroup.Group eg.Go(func() error { - owners, err := api.Database.GetUsers(ctx, database.GetUsersParams{ + var err error + owners, err = api.Database.GetUsers(ctx, database.GetUsersParams{ RbacRole: []string{codersdk.RoleOwner}, }) if err != nil { - return xerrors.Errorf("get owner: %w", err) + return xerrors.Errorf("get owners: %w", err) } return nil }) eg.Go(func() error { - userAdmin, err := api.Database.GetUsers(ctx, database.GetUsersParams{ + var err error + userAdmins, err = api.Database.GetUsers(ctx, database.GetUsersParams{ RbacRole: []string{codersdk.RoleOrganizationUserAdmin}, }) if err != nil { @@ -1302,7 +1307,16 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return database.User{}, uuid.Nil, err } - // Enqueue N notifications + for _, u := range append(owners, userAdmins...) { + if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountCreated, + map[string]string{ + "user_account_name": user.Name, + }, "api-users-create", + u.ID, + ); err != nil { + api.Logger.Warn(ctx, "unable to notify about created user", slog.F("created_user", user.Name), slog.Error(err)) + } + } } return user, req.OrganizationID, err } From 00275dd718ec8261304737e438c25c5ef1557dba Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 15:05:14 +0200 Subject: [PATCH 10/23] fix: versions --- coderd/database/models.go | 2 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/database/models.go b/coderd/database/models.go index 9cae6c32833f0..0ee78e286516e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ccdcd101255dc..9d0494813e306 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d4dd192eeb350..2e3a5c9892d40 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.25.0 package database From e356ba8463a0da39ccbf049c6375fc94b605e12f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 15:16:22 +0200 Subject: [PATCH 11/23] test --- coderd/users_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/coderd/users_test.go b/coderd/users_test.go index 7c19096105a95..d414bb7c17571 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,40 @@ func TestPostUsers(t *testing.T) { }) } +func TestNotifyCreatedUser(t *testing.T) { + t.Parallel() + + t.Run("OwnerNotified", func(t *testing.T) { + t.Parallel() + + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + client := coderdtest.New(t, &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + require.Len(t, user.OrganizationIDs, 1) + assert.Equal(t, firstUser.OrganizationID, user.OrganizationIDs[0]) + + 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, user.ID, notifyEnq.Sent[0].Targets) + require.Equal(t, user.Username, notifyEnq.Sent[0].Labels["user_account_name"]) + }) +} + func TestUpdateUserProfile(t *testing.T) { t.Parallel() t.Run("UserNotFound", func(t *testing.T) { From 6bc1d2ddf56ab4cbc7ea0fbb6e718688f7e0548b Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 15:54:41 +0200 Subject: [PATCH 12/23] fix test --- coderd/users.go | 4 ++-- coderd/users_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index a50436c171921..3e508a2903beb 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1310,9 +1310,9 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create for _, u := range append(owners, userAdmins...) { if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountCreated, map[string]string{ - "user_account_name": user.Name, + "user_account_name": user.Username, }, "api-users-create", - u.ID, + user.ID, ); err != nil { api.Logger.Warn(ctx, "unable to notify about created user", slog.F("created_user", user.Name), slog.Error(err)) } diff --git a/coderd/users_test.go b/coderd/users_test.go index d414bb7c17571..83daa1605fbfb 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -628,7 +628,7 @@ func TestNotifyCreatedUser(t *testing.T) { 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, user.ID, notifyEnq.Sent[0].Targets) + require.Contains(t, notifyEnq.Sent[0].Targets, user.ID) require.Equal(t, user.Username, notifyEnq.Sent[0].Labels["user_account_name"]) }) } From 67a513762e620c61e224527ea249c04dd7257ffa Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 16:32:46 +0200 Subject: [PATCH 13/23] users notified --- coderd/users.go | 2 +- coderd/users_test.go | 66 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 3e508a2903beb..e53af03b81a41 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1295,7 +1295,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create eg.Go(func() error { var err error userAdmins, err = api.Database.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleOrganizationUserAdmin}, + RbacRole: []string{codersdk.RoleUserAdmin}, }) if err != nil { return xerrors.Errorf("get user admins: %w", err) diff --git a/coderd/users_test.go b/coderd/users_test.go index 83daa1605fbfb..cd61bf1e95f7e 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -606,15 +606,15 @@ func TestNotifyCreatedUser(t *testing.T) { t.Parallel() notifyEnq := &testutil.FakeNotificationsEnqueuer{} - client := coderdtest.New(t, &coderdtest.Options{ + adminClient := coderdtest.New(t, &coderdtest.Options{ NotificationsEnqueuer: notifyEnq, }) - firstUser := coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, adminClient) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + user, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ OrganizationID: firstUser.OrganizationID, Email: "another@user.org", Username: "someone-else", @@ -622,15 +622,69 @@ func TestNotifyCreatedUser(t *testing.T) { }) require.NoError(t, err) - require.Len(t, user.OrganizationIDs, 1) - assert.Equal(t, firstUser.OrganizationID, user.OrganizationIDs[0]) - 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["user_account_name"]) }) + + t.Run("UserAdminNotified", func(t *testing.T) { + t.Parallel() + + 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) + + member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: firstUser.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + }) + require.NoError(t, err) + + 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["user_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["user_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["user_account_name"]) + + }) } func TestUpdateUserProfile(t *testing.T) { From 42d9ba19a6e19b7a89def15eecbd091edf5cf642 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 16:34:54 +0200 Subject: [PATCH 14/23] post merge --- ...reated.down.sql => 000233_notifications_user_created.down.sql} | 0 ...er_created.up.sql => 000233_notifications_user_created.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000232_notifications_user_created.down.sql => 000233_notifications_user_created.down.sql} (100%) rename coderd/database/migrations/{000232_notifications_user_created.up.sql => 000233_notifications_user_created.up.sql} (100%) diff --git a/coderd/database/migrations/000232_notifications_user_created.down.sql b/coderd/database/migrations/000233_notifications_user_created.down.sql similarity index 100% rename from coderd/database/migrations/000232_notifications_user_created.down.sql rename to coderd/database/migrations/000233_notifications_user_created.down.sql diff --git a/coderd/database/migrations/000232_notifications_user_created.up.sql b/coderd/database/migrations/000233_notifications_user_created.up.sql similarity index 100% rename from coderd/database/migrations/000232_notifications_user_created.up.sql rename to coderd/database/migrations/000233_notifications_user_created.up.sql From ecc7d3080c6d46466634aea06af0d0f20e2ad734 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 16:35:25 +0200 Subject: [PATCH 15/23] fmt --- coderd/users_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/users_test.go b/coderd/users_test.go index cd61bf1e95f7e..46230bb64f390 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -683,7 +683,6 @@ func TestNotifyCreatedUser(t *testing.T) { 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["user_account_name"]) - }) } From b2dcb3b19ec2eab49b7f53ffe628d82e88161b37 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 29 Jul 2024 16:37:02 +0200 Subject: [PATCH 16/23] skip notif --- coderd/users.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/users.go b/coderd/users.go index e53af03b81a41..122cbbecb317d 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1277,7 +1277,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create } return nil }, nil) - if err == nil { + if err == nil && !req.SkipNotifications { // Notify user admins // Get all users with user admin permission including owners var owners, userAdmins []database.GetUsersRow From 70e2d2c2311c1d48011c8832b4d4b03e67822c02 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 11:38:55 +0200 Subject: [PATCH 17/23] Danny's feedback --- coderd/notifications/events.go | 3 ++ coderd/users.go | 70 +++++++++++++++++----------------- coderd/users_test.go | 8 ++-- 3 files changed, 43 insertions(+), 38 deletions(-) diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index c55043368fab1..9908a3e06adfb 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -12,6 +12,9 @@ var ( TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0") 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 122cbbecb317d..1d6100bf34cbf 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1277,45 +1277,47 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create } return nil }, nil) - if err == nil && !req.SkipNotifications { - // Notify user admins - // Get all users with user admin permission including owners - var owners, userAdmins []database.GetUsersRow - var eg errgroup.Group - eg.Go(func() error { - var err error - owners, err = api.Database.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleOwner}, - }) - if err != nil { - return xerrors.Errorf("get owners: %w", err) - } - return nil + if err != nil || req.SkipNotifications { + return user, req.OrganizationID, err + } + + // Notify user admins + // Get all users with user admin permission including owners + var owners, userAdmins []database.GetUsersRow + var eg errgroup.Group + eg.Go(func() error { + var err error + owners, err = api.Database.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleOwner}, }) - eg.Go(func() error { - var err error - userAdmins, err = api.Database.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleUserAdmin}, - }) - if err != nil { - return xerrors.Errorf("get user admins: %w", err) - } - return nil + if err != nil { + return xerrors.Errorf("get owners: %w", err) + } + return nil + }) + eg.Go(func() error { + var err error + userAdmins, err = api.Database.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleUserAdmin}, }) - err := eg.Wait() if err != nil { - return database.User{}, uuid.Nil, err + return xerrors.Errorf("get user admins: %w", err) } + return nil + }) + err = eg.Wait() + if err != nil { + return database.User{}, uuid.Nil, err + } - for _, u := range append(owners, userAdmins...) { - if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, notifications.TemplateUserAccountCreated, - map[string]string{ - "user_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.Name), slog.Error(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 diff --git a/coderd/users_test.go b/coderd/users_test.go index 46230bb64f390..4e6ce5a636ff3 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -626,7 +626,7 @@ func TestNotifyCreatedUser(t *testing.T) { 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["user_account_name"]) + require.Equal(t, user.Username, notifyEnq.Sent[0].Labels["created_account_name"]) }) t.Run("UserAdminNotified", func(t *testing.T) { @@ -670,19 +670,19 @@ func TestNotifyCreatedUser(t *testing.T) { 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["user_account_name"]) + 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["user_account_name"]) + 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["user_account_name"]) + require.Equal(t, member.Username, notifyEnq.Sent[2].Labels["created_account_name"]) }) } From 8696b708d6bb28cfa9ddfb16969468aa92e99555 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 11:41:38 +0200 Subject: [PATCH 18/23] given when then --- coderd/users_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/coderd/users_test.go b/coderd/users_test.go index 4e6ce5a636ff3..82b984226d2b2 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -605,6 +605,7 @@ func TestNotifyCreatedUser(t *testing.T) { t.Run("OwnerNotified", func(t *testing.T) { t.Parallel() + // given notifyEnq := &testutil.FakeNotificationsEnqueuer{} adminClient := coderdtest.New(t, &coderdtest.Options{ NotificationsEnqueuer: notifyEnq, @@ -614,6 +615,7 @@ func TestNotifyCreatedUser(t *testing.T) { 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", @@ -622,6 +624,7 @@ func TestNotifyCreatedUser(t *testing.T) { }) 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) @@ -632,6 +635,7 @@ func TestNotifyCreatedUser(t *testing.T) { t.Run("UserAdminNotified", func(t *testing.T) { t.Parallel() + // given notifyEnq := &testutil.FakeNotificationsEnqueuer{} adminClient := coderdtest.New(t, &coderdtest.Options{ NotificationsEnqueuer: notifyEnq, @@ -656,6 +660,7 @@ func TestNotifyCreatedUser(t *testing.T) { }) require.NoError(t, err) + // when member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ OrganizationID: firstUser.OrganizationID, Email: "another@user.org", @@ -664,6 +669,7 @@ func TestNotifyCreatedUser(t *testing.T) { }) require.NoError(t, err) + // then require.Len(t, notifyEnq.Sent, 3) // "User admin" account created, "owner" notified From a92d059fe57f62bee59dc521594f282cedded980 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 11:56:40 +0200 Subject: [PATCH 19/23] Fix tests --- coderd/autobuild/lifecycle_executor_test.go | 15 ++++++++------- coderd/workspaces_test.go | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) 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/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) { From b5b0d90329a2cee7e15916f32b4d1f8489e06c09 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 12:45:48 +0200 Subject: [PATCH 20/23] Skip notifications --- enterprise/coderd/scim_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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) { From b843232b7b3244bf27cd1b559781e5f3375320c4 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 13:11:13 +0200 Subject: [PATCH 21/23] fix: created_account_name --- .../migrations/000233_notifications_user_created.up.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/migrations/000233_notifications_user_created.up.sql b/coderd/database/migrations/000233_notifications_user_created.up.sql index 1ee369e5a6c47..4292bfed44986 100644 --- a/coderd/database/migrations/000233_notifications_user_created.up.sql +++ b/coderd/database/migrations/000233_notifications_user_created.up.sql @@ -1,6 +1,6 @@ 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.user_account_name}}" created', - E'Hi {{.UserName}},\n\New user account **{{.Labels.user_account_name}}** has been created.', +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", From 2a53a0baf080d1ddb10868baad0baf7c0fe65070 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 15:16:10 +0200 Subject: [PATCH 22/23] api.Database -> tx --- coderd/users.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 1d6100bf34cbf..60c9afcf7472f 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1287,7 +1287,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create var eg errgroup.Group eg.Go(func() error { var err error - owners, err = api.Database.GetUsers(ctx, database.GetUsersParams{ + owners, err = store.GetUsers(ctx, database.GetUsersParams{ RbacRole: []string{codersdk.RoleOwner}, }) if err != nil { @@ -1297,7 +1297,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create }) eg.Go(func() error { var err error - userAdmins, err = api.Database.GetUsers(ctx, database.GetUsersParams{ + userAdmins, err = store.GetUsers(ctx, database.GetUsersParams{ RbacRole: []string{codersdk.RoleUserAdmin}, }) if err != nil { From 77c0c28e8af6e863a892f6045ccaca26d44e4180 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 30 Jul 2024 15:26:07 +0200 Subject: [PATCH 23/23] pq issue --- coderd/users.go | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 60c9afcf7472f..565aeca1cb2a8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -10,7 +10,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "cdr.dev/slog" @@ -1281,33 +1280,20 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return user, req.OrganizationID, err } - // Notify user admins - // Get all users with user admin permission including owners - var owners, userAdmins []database.GetUsersRow - var eg errgroup.Group - eg.Go(func() error { - var err error - owners, err = store.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleOwner}, - }) - if err != nil { - return xerrors.Errorf("get owners: %w", err) - } - return nil + // 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}, }) - eg.Go(func() error { - var err error - userAdmins, err = store.GetUsers(ctx, database.GetUsersParams{ - RbacRole: []string{codersdk.RoleUserAdmin}, - }) - if err != nil { - return xerrors.Errorf("get user admins: %w", err) - } - return nil + if err != nil { + return user, req.OrganizationID, xerrors.Errorf("get owners: %w", err) + } + userAdmins, err := store.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleUserAdmin}, }) - err = eg.Wait() if err != nil { - return database.User{}, uuid.Nil, err + return user, req.OrganizationID, xerrors.Errorf("get user admins: %w", err) } for _, u := range append(owners, userAdmins...) {