diff --git a/coderd/database/migrations/000244_notifications_delete_template.down.sql b/coderd/database/migrations/000244_notifications_delete_template.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000244_notifications_delete_template.up.sql b/coderd/database/migrations/000244_notifications_delete_template.up.sql new file mode 100644 index 0000000000000..1dbc985f52566 --- /dev/null +++ b/coderd/database/migrations/000244_notifications_delete_template.up.sql @@ -0,0 +1,22 @@ +INSERT INTO + notification_templates ( + id, + name, + title_template, + body_template, + "group", + actions + ) +VALUES ( + '29a09665-2a4c-403f-9648-54301670e7be', + 'Template Deleted', + E'Template "{{.Labels.name}}" deleted', + E'Hi {{.UserName}}\n\nThe template **{{.Labels.name}}** was deleted by **{{ .Labels.initiator }}**.', + 'Template Events', + '[ + { + "label": "View templates", + "url": "{{ base_url }}/templates" + } + ]'::jsonb + ); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 94260317e20c9..b340b281e0757 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -19,3 +19,8 @@ var ( TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2") TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6") ) + +// Template-related events. +var ( + TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be") +) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 077018b32581c..e21dce4246f20 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -740,6 +740,17 @@ func TestNotificationTemplatesCanRender(t *testing.T) { }, }, }, + { + name: "TemplateTemplateDeleted", + id: notifications.TemplateTemplateDeleted, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "name": "bobby-template", + "initiator": "rob", + }, + }, + }, } allTemplates, err := enumerateAllTemplates(t) diff --git a/coderd/templates.go b/coderd/templates.go index 93a5943b40193..ade849ba99c9c 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "errors" "fmt" @@ -12,12 +13,15 @@ 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/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "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/schedule" @@ -56,6 +60,7 @@ func (api *API) template(rw http.ResponseWriter, r *http.Request) { // @Router /templates/{template} [delete] func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) { var ( + apiKey = httpmw.APIKey(r) ctx = r.Context() template = httpmw.TemplateParam(r) auditor = *api.Auditor.Load() @@ -101,11 +106,47 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) { }) return } + + admins, err := findTemplateAdmins(ctx, api.Database) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template admins.", + Detail: err.Error(), + }) + return + } + for _, admin := range admins { + // Don't send notification to user which initiated the event. + if admin.ID == apiKey.UserID { + continue + } + api.notifyTemplateDeleted(ctx, template, apiKey.UserID, admin.ID) + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ Message: "Template has been deleted!", }) } +func (api *API) notifyTemplateDeleted(ctx context.Context, template database.Template, initiatorID uuid.UUID, receiverID uuid.UUID) { + initiator, err := api.Database.GetUserByID(ctx, initiatorID) + if err != nil { + api.Logger.Warn(ctx, "failed to fetch initiator for template deletion notification", slog.F("initiator_id", initiatorID), slog.Error(err)) + return + } + + if _, err := api.NotificationsEnqueuer.Enqueue(ctx, receiverID, notifications.TemplateTemplateDeleted, + map[string]string{ + "name": template.Name, + "initiator": initiator.Username, + }, "api-templates-delete", + // Associate this notification with all the related entities. + template.ID, template.OrganizationID, + ); err != nil { + api.Logger.Warn(ctx, "failed to notify of template deletion", slog.F("deleted_template_id", template.ID), slog.Error(err)) + } +} + // Create a new template in an organization. // Returns a single template. // @@ -948,3 +989,22 @@ func (api *API) convertTemplate( MaxPortShareLevel: maxPortShareLevel, } } + +// findTemplateAdmins fetches all users with template admin permission including owners. +func findTemplateAdmins(ctx context.Context, store database.Store) ([]database.GetUsersRow, error) { + // 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 nil, xerrors.Errorf("get owners: %w", err) + } + templateAdmins, err := store.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleTemplateAdmin}, + }) + if err != nil { + return nil, xerrors.Errorf("get template admins: %w", err) + } + return append(owners, templateAdmins...), nil +} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 4d6073d6ab835..5efd127681bbd 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/util/ptr" @@ -1326,3 +1327,98 @@ func TestTemplateMetrics(t *testing.T) { dbtime.Now(), res.Workspaces[0].LastUsedAt, time.Minute, ) } + +func TestTemplateNotifications(t *testing.T) { + t.Parallel() + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + t.Run("InitiatorIsNotNotified", func(t *testing.T) { + t.Parallel() + + // Given: an initiator + var ( + notifyEnq = &testutil.FakeNotificationsEnqueuer{} + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, + }) + initiator = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, initiator.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template = coderdtest.CreateTemplate(t, client, initiator.OrganizationID, version.ID) + ctx = testutil.Context(t, testutil.WaitLong) + ) + + // When: the template is deleted by the initiator + err := client.DeleteTemplate(ctx, template.ID) + require.NoError(t, err) + + // Then: the delete notification is not sent to the initiator. + deleteNotifications := make([]*testutil.Notification, 0) + for _, n := range notifyEnq.Sent { + if n.TemplateID == notifications.TemplateTemplateDeleted { + deleteNotifications = append(deleteNotifications, n) + } + } + require.Len(t, deleteNotifications, 0) + }) + + t.Run("OnlyOwnersAndAdminsAreNotified", func(t *testing.T) { + t.Parallel() + + // Given: multiple users with different roles + var ( + notifyEnq = &testutil.FakeNotificationsEnqueuer{} + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, + }) + initiator = coderdtest.CreateFirstUser(t, client) + ctx = testutil.Context(t, testutil.WaitLong) + + // Setup template + version = coderdtest.CreateTemplateVersion(t, client, initiator.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template = coderdtest.CreateTemplate(t, client, initiator.OrganizationID, version.ID) + ) + + // Setup users with different roles + _, owner := coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleOwner()) + _, tmplAdmin := coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleTemplateAdmin()) + coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleMember()) + coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleUserAdmin()) + coderdtest.CreateAnotherUser(t, client, initiator.OrganizationID, rbac.RoleAuditor()) + + // When: the template is deleted by the initiator + err := client.DeleteTemplate(ctx, template.ID) + require.NoError(t, err) + + // Then: only owners and template admins should receive the + // notification. + shouldBeNotified := []uuid.UUID{owner.ID, tmplAdmin.ID} + var deleteTemplateNotifications []*testutil.Notification + for _, n := range notifyEnq.Sent { + if n.TemplateID == notifications.TemplateTemplateDeleted { + deleteTemplateNotifications = append(deleteTemplateNotifications, n) + } + } + notifiedUsers := make([]uuid.UUID, 0, len(deleteTemplateNotifications)) + for _, n := range deleteTemplateNotifications { + notifiedUsers = append(notifiedUsers, n.UserID) + } + require.ElementsMatch(t, shouldBeNotified, notifiedUsers) + + // Validate the notification content + for _, n := range deleteTemplateNotifications { + require.Equal(t, n.TemplateID, notifications.TemplateTemplateDeleted) + require.Contains(t, notifiedUsers, n.UserID) + require.Contains(t, n.Targets, template.ID) + require.Contains(t, n.Targets, template.OrganizationID) + require.Equal(t, n.Labels["name"], template.Name) + require.Equal(t, n.Labels["initiator"], coderdtest.FirstUserParams.Username) + } + }) + }) +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 2bbbf171eab61..ec7c03dd53013 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3441,7 +3441,7 @@ func TestWorkspaceUsageTracking(t *testing.T) { }) } -func TestNotifications(t *testing.T) { +func TestWorkspaceNotifications(t *testing.T) { t.Parallel() t.Run("Dormant", func(t *testing.T) {