feat: add template delete notification (#14250)

This commit is contained in:
Bruno Quaresma
2024-08-14 14:22:43 -03:00
committed by GitHub
parent 86b9c97e8e
commit 6f1951e1c8
7 changed files with 195 additions and 1 deletions
@@ -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
);
+5
View File
@@ -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")
)
@@ -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)
+60
View File
@@ -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
}
+96
View File
@@ -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)
}
})
})
}
+1 -1
View File
@@ -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) {