From 2b236e81f419a917ef4c15773f959853c8f682cb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 23 Oct 2024 10:45:54 +0000 Subject: [PATCH 01/11] feat: notify users on template deprecation --- coderd/database/dbauthz/dbauthz.go | 10 +++++ coderd/database/dbauthz/dbauthz_test.go | 4 ++ coderd/database/dbmem/dbmem.go | 27 +++++++++++++ coderd/database/dbmetrics/dbmetrics.go | 7 ++++ coderd/database/dbmock/dbmock.go | 15 ++++++++ ...template_deprecation_notification.down.sql | 1 + ...9_template_deprecation_notification.up.sql | 13 +++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 38 +++++++++++++++++++ coderd/database/queries/templates.sql | 15 ++++++++ coderd/notifications/events.go | 3 +- coderd/templates.go | 34 +++++++++++++++++ 12 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 coderd/database/migrations/000269_template_deprecation_notification.down.sql create mode 100644 coderd/database/migrations/000269_template_deprecation_notification.up.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 052f25450e6a5..99c4abcd0d27d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2339,6 +2339,16 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas return q.db.GetUsersByIDs(ctx, ids) } +func (q *querier) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { + // Ensure we have permission to access this template. + _, err := q.GetTemplateByID(ctx, id) + if err != nil { + return nil, err + } + + return q.db.GetUsersWithAccessToTemplateByID(ctx, id) +} + 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/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 6a34e88104ce1..4032b41526573 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1131,6 +1131,10 @@ func (s *MethodTestSuite) TestUser() { Asserts(a, policy.ActionRead, b, policy.ActionRead). Returns(slice.New(a, b)) })) + s.Run("GetUsersWithAccessToTemplateByID", s.Subtest(func(db database.Store, check *expects) { + a := dbgen.Template(s.T(), db, database.Template{}) + check.Args(a.ID).Asserts(a, policy.ActionRead) + })) s.Run("GetUsers", s.Subtest(func(db database.Store, check *expects) { dbgen.User(s.T(), db, database.User{Username: "GetUsers-a-user"}) dbgen.User(s.T(), db, database.User{Username: "GetUsers-b-user"}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 24498d88c9dbc..5dabfa3258ebf 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5669,6 +5669,33 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } +func (q *FakeQuerier) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + groups := make(map[string]bool, 0) + for _, template := range q.templates { + if template.ID != id { + continue + } + + for group := range template.GroupACL { + groups[group] = true + } + } + + users := make([]uuid.UUID, 0) + for _, member := range q.organizationMembers { + if _, ok := groups[member.OrganizationID.String()]; !ok { + continue + } + + users = append(users, member.UserID) + } + + return users, nil +} + 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 c3e9de22fb0d8..79f5dbb7d7f30 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1348,6 +1348,13 @@ func (m metricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]dat return users, err } +func (m metricsStore) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.GetUsersWithAccessToTemplateByID(ctx, id) + m.queryLatencies.WithLabelValues("GetUsersWithAccessToTemplateByID").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 b3c7b9e7615d3..d2a4b5b2df8fe 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2813,6 +2813,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) } +// GetUsersWithAccessToTemplateByID mocks base method. +func (m *MockStore) GetUsersWithAccessToTemplateByID(arg0 context.Context, arg1 uuid.UUID) ([]uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersWithAccessToTemplateByID", arg0, arg1) + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsersWithAccessToTemplateByID indicates an expected call of GetUsersWithAccessToTemplateByID. +func (mr *MockStoreMockRecorder) GetUsersWithAccessToTemplateByID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersWithAccessToTemplateByID", reflect.TypeOf((*MockStore)(nil).GetUsersWithAccessToTemplateByID), arg0, arg1) +} + // 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/migrations/000269_template_deprecation_notification.down.sql b/coderd/database/migrations/000269_template_deprecation_notification.down.sql new file mode 100644 index 0000000000000..b3f9abc0133bd --- /dev/null +++ b/coderd/database/migrations/000269_template_deprecation_notification.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = 'f40fae84-55a2-42cd-99fa-b41c1ca64894'; diff --git a/coderd/database/migrations/000269_template_deprecation_notification.up.sql b/coderd/database/migrations/000269_template_deprecation_notification.up.sql new file mode 100644 index 0000000000000..cb8dcf4272571 --- /dev/null +++ b/coderd/database/migrations/000269_template_deprecation_notification.up.sql @@ -0,0 +1,13 @@ +INSERT INTO notification_templates + (id, name, title_template, body_template, "group", actions) +VALUES ( + 'f40fae84-55a2-42cd-99fa-b41c1ca64894', + 'Template Deprecated', + E'Template **{{.Labels.template}}** has been deprecated', + E'Hello {{.UserName}},\n\n'|| + E'The template **{{.Labels.template}}** has been deprecated with the following message:\n\n' || + E'**{{.Labels.message}}**\n\n' || + E'New workspaces may not be created from this template. Existing workspaces will continue to function normally.', + 'Template Events', + '[]'::jsonb +); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index fcb58a7d6e305..1e8f6909b1d86 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -290,6 +290,7 @@ 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) + GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, 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 45cbef3f5e1d8..f0a290d2a2f8b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8557,6 +8557,44 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate return items, nil } +const getUsersWithAccessToTemplateByID = `-- name: GetUsersWithAccessToTemplateByID :many +SELECT + user_id +FROM + organization_members +WHERE + organization_members.organization_id::text IN ( + SELECT + jsonb_object_keys(group_acl) + FROM + templates + WHERE templates.id = $1 + ) +` + +func (q *sqlQuerier) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.QueryContext(ctx, getUsersWithAccessToTemplateByID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var user_id uuid.UUID + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertTemplate = `-- name: InsertTemplate :exec INSERT INTO templates ( diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 84df9633a1a53..eb270d4d491a6 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -199,3 +199,18 @@ SET WHERE id = $1 ; + +-- name: GetUsersWithAccessToTemplateByID :many +SELECT + user_id +FROM + organization_members +WHERE + organization_members.organization_id::text IN ( + SELECT + jsonb_object_keys(group_acl) + FROM + templates + WHERE templates.id = $1 + ) +; diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index c2e0f442e0623..e33a85b523db2 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -30,7 +30,8 @@ var ( // Template-related events. var ( - TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be") + TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be") + TemplateTemplateDeprecated = uuid.MustParse("f40fae84-55a2-42cd-99fa-b41c1ca64894") TemplateWorkspaceBuildsFailedReport = uuid.MustParse("34a20db2-e9cc-4a93-b0e4-8569699d7a00") ) diff --git a/coderd/templates.go b/coderd/templates.go index 907a4d1265836..4ea0196d4fced 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -845,6 +845,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return } + if template.Deprecated != updated.Deprecated && updated.Deprecated != "" { + if err := api.notifyUsersOfTemplateDeprecation(ctx, updated); err != nil { + api.Logger.Error(ctx, "failed to notify users of template deprecation", slog.Error(err)) + } + } + if updated.UpdatedAt.IsZero() { aReq.New = template rw.WriteHeader(http.StatusNotModified) @@ -855,6 +861,34 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(updated)) } +func (api *API) notifyUsersOfTemplateDeprecation(ctx context.Context, template database.Template) error { + users, err := api.Database.GetUsersWithAccessToTemplateByID(ctx, template.ID) + if err != nil { + return xerrors.Errorf("get users with access to template by id: %w", err) + } + + errs := []error{} + + for _, userID := range users { + _, err = api.NotificationsEnqueuer.Enqueue( + //nolint:gocritic // We need the system auth context to be able to send the deprecation notification. + dbauthz.AsSystemRestricted(ctx), + userID, + notifications.TemplateTemplateDeprecated, + map[string]string{ + "template": template.Name, + "message": template.Deprecated, + }, + "notify-users-of-template-deprecation", + ) + if err != nil { + errs = append(errs, xerrors.Errorf("enqueue notification: %w", err)) + } + } + + return errors.Join(errs...) +} + // @Summary Get template DAUs by ID // @ID get-template-daus-by-id // @Security CoderSessionToken From 640138acc07fdaf124ad6fce0cb3b30952d53cb4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 23 Oct 2024 12:30:49 +0000 Subject: [PATCH 02/11] test: test behaviour --- coderd/database/dbmem/dbmem.go | 4 ++-- enterprise/coderd/templates_test.go | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 5dabfa3258ebf..17115af3214c9 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5673,14 +5673,14 @@ func (q *FakeQuerier) GetUsersWithAccessToTemplateByID(ctx context.Context, id u q.mutex.RLock() defer q.mutex.RUnlock() - groups := make(map[string]bool, 0) + groups := make(map[string]struct{}, 0) for _, template := range q.templates { if template.ID != id { continue } for group := range template.GroupACL { - groups[group] = true + groups[group] = struct{}{} } } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 5d9cb8ee9fa35..290fd4454b941 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "net/http" + "slices" "testing" "time" @@ -38,9 +39,11 @@ func TestTemplates(t *testing.T) { t.Run("Deprecated", func(t *testing.T) { t.Parallel() + notifyEnq := &testutil.FakeNotificationsEnqueuer{} owner, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -48,7 +51,7 @@ func TestTemplates(t *testing.T) { }, }, }) - client, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + client, anotherUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -65,6 +68,25 @@ func TestTemplates(t *testing.T) { assert.True(t, updated.Deprecated) assert.NotEmpty(t, updated.DeprecationMessage) + notifs := []*testutil.Notification{} + for _, notif := range notifyEnq.Sent { + if notif.TemplateID == notifications.TemplateTemplateDeprecated { + notifs = append(notifs, notif) + } + } + require.Equal(t, 2, len(notifs)) + + expectedSentTo := []string{user.UserID.String(), anotherUser.ID.String()} + slices.Sort(expectedSentTo) + + sentTo := []string{} + for _, notif := range notifs { + sentTo = append(sentTo, notif.UserID.String()) + } + slices.Sort(sentTo) + + assert.Equal(t, expectedSentTo, sentTo) + _, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, Name: "foobar", From 22ec59dd1a07d03da5481cd040e78a569b1a0530 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 23 Oct 2024 13:05:20 +0000 Subject: [PATCH 03/11] fix: ci --- coderd/database/dbmem/dbmem.go | 2 +- ...wn.sql => 000270_template_deprecation_notification.down.sql} | 0 ...n.up.sql => 000270_template_deprecation_notification.up.sql} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename coderd/database/migrations/{000269_template_deprecation_notification.down.sql => 000270_template_deprecation_notification.down.sql} (100%) rename coderd/database/migrations/{000269_template_deprecation_notification.up.sql => 000270_template_deprecation_notification.up.sql} (100%) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 17115af3214c9..1ac9074006949 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5669,7 +5669,7 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } -func (q *FakeQuerier) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { +func (q *FakeQuerier) GetUsersWithAccessToTemplateByID(_ context.Context, id uuid.UUID) ([]uuid.UUID, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/migrations/000269_template_deprecation_notification.down.sql b/coderd/database/migrations/000270_template_deprecation_notification.down.sql similarity index 100% rename from coderd/database/migrations/000269_template_deprecation_notification.down.sql rename to coderd/database/migrations/000270_template_deprecation_notification.down.sql diff --git a/coderd/database/migrations/000269_template_deprecation_notification.up.sql b/coderd/database/migrations/000270_template_deprecation_notification.up.sql similarity index 100% rename from coderd/database/migrations/000269_template_deprecation_notification.up.sql rename to coderd/database/migrations/000270_template_deprecation_notification.up.sql From cd97285cdd0e9eea6f515ba8141ec24058a0b2bc Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 23 Oct 2024 14:41:18 +0000 Subject: [PATCH 04/11] feat: add CTAs --- .../000270_template_deprecation_notification.up.sql | 13 +++++++++++-- coderd/templates.go | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/coderd/database/migrations/000270_template_deprecation_notification.up.sql b/coderd/database/migrations/000270_template_deprecation_notification.up.sql index cb8dcf4272571..1a289f448f5d0 100644 --- a/coderd/database/migrations/000270_template_deprecation_notification.up.sql +++ b/coderd/database/migrations/000270_template_deprecation_notification.up.sql @@ -3,11 +3,20 @@ INSERT INTO notification_templates VALUES ( 'f40fae84-55a2-42cd-99fa-b41c1ca64894', 'Template Deprecated', - E'Template **{{.Labels.template}}** has been deprecated', + E'Template ''{{.Labels.template}}'' has been deprecated', E'Hello {{.UserName}},\n\n'|| E'The template **{{.Labels.template}}** has been deprecated with the following message:\n\n' || E'**{{.Labels.message}}**\n\n' || E'New workspaces may not be created from this template. Existing workspaces will continue to function normally.', 'Template Events', - '[]'::jsonb + '[ + { + "label": "See workspaces", + "url": "{{base_url}}/workspaces?filter=owner%3Ame+template%3A{{.Labels.template}}" + }, + { + "label": "View template", + "url": "{{base_url}}/templates/{{.Labels.organization}}/{{.Labels.template}}" + } + ]'::jsonb ); diff --git a/coderd/templates.go b/coderd/templates.go index 4ea0196d4fced..14dced44a68ae 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -876,8 +876,9 @@ func (api *API) notifyUsersOfTemplateDeprecation(ctx context.Context, template d userID, notifications.TemplateTemplateDeprecated, map[string]string{ - "template": template.Name, - "message": template.Deprecated, + "template": template.Name, + "message": template.Deprecated, + "organization": template.OrganizationName, }, "notify-users-of-template-deprecation", ) From 5732b2656ae5ba4b6402596b399e6cddb8aa74be Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 23 Oct 2024 15:53:00 +0000 Subject: [PATCH 05/11] test: add rendered-template test --- coderd/notifications/notifications_test.go | 14 +++ .../TemplateTemplateDeprecated.html.golden | 98 +++++++++++++++++++ .../TemplateTemplateDeprecated.json.golden | 33 +++++++ 3 files changed, 145 insertions(+) create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 4a6978b5024fe..86ed14fe90957 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1021,6 +1021,20 @@ func TestNotificationTemplates_Golden(t *testing.T) { appName: "Custom Application Name", logoURL: "https://custom.application/logo.png", }, + { + name: "TemplateTemplateDeprecated", + id: notifications.TemplateTemplateDeprecated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "template": "alpha", + "message": "This template has been replaced by beta", + "organization": "coder", + }, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden new file mode 100644 index 0000000000000..b627c6f8aafcf --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden @@ -0,0 +1,98 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Template 'alpha' has been deprecated +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hello Bobby, + +The template alpha has been deprecated with the following message: + +This template has been replaced by beta + +New workspaces may not be created from this template. Existing workspaces w= +ill continue to function normally. + + +See workspaces: http://test.com/workspaces?filter=3Downer%3Ame+template%3Aa= +lpha + +View template: http://test.com/templates/coder/alpha + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Codestin Search App + + +
+
+ 3D"Cod= +
+

+ Template 'alpha' has been deprecated +

+
+

Hello Bobby,

+ +

The template alpha has been deprecated with the followi= +ng message:

+ +

This template has been replaced by beta

+ +

New workspaces may not be created from this template. Existing workspace= +s will continue to function normally.

+
+ + +
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden new file mode 100644 index 0000000000000..206cbba5e1abb --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateTemplateDeprecated.json.golden @@ -0,0 +1,33 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.1", + "notification_name": "Template Deprecated", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "See workspaces", + "url": "http://test.com/workspaces?filter=owner%3Ame+template%3Aalpha" + }, + { + "label": "View template", + "url": "http://test.com/templates/coder/alpha" + } + ], + "labels": { + "message": "This template has been replaced by beta", + "organization": "coder", + "template": "alpha" + }, + "data": null + }, + "title": "Template 'alpha' has been deprecated", + "title_markdown": "Template 'alpha' has been deprecated", + "body": "Hello Bobby,\n\nThe template alpha has been deprecated with the following message:\n\nThis template has been replaced by beta\n\nNew workspaces may not be created from this template. Existing workspaces will continue to function normally.", + "body_markdown": "Hello Bobby,\n\nThe template **alpha** has been deprecated with the following message:\n\n**This template has been replaced by beta**\n\nNew workspaces may not be created from this template. Existing workspaces will continue to function normally." +} \ No newline at end of file From a9717121fa56d441fc6f9498d07feacd0644562f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 24 Oct 2024 09:31:48 +0000 Subject: [PATCH 06/11] test: ensure notification goes to correct users --- enterprise/coderd/templates_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 290fd4454b941..1d0cb13306fe8 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -47,7 +47,8 @@ func TestTemplates(t *testing.T) { }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ - codersdk.FeatureAccessControl: 1, + codersdk.FeatureAccessControl: 1, + codersdk.FeatureMultipleOrganizations: 1, }, }, }) @@ -56,6 +57,9 @@ func TestTemplates(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + org := coderdenttest.CreateOrganization(t, owner, coderdenttest.CreateOrganizationOptions{}) + _, thirdUser := coderdtest.CreateAnotherUser(t, owner, org.ID, rbac.RoleTemplateAdmin()) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -85,8 +89,15 @@ func TestTemplates(t *testing.T) { } slices.Sort(sentTo) + // Require the notification to have only been sent to the expected users assert.Equal(t, expectedSentTo, sentTo) + // The previous check should verify this but we're double checking that + // the notification wasn't sent to a user in another org. + for _, notif := range notifs { + assert.NotEqual(t, thirdUser.ID, notif.UserID) + } + _, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, Name: "foobar", From 800adadb1f959ab7dcb57104bc5104381f5b00d4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 24 Oct 2024 10:34:59 +0000 Subject: [PATCH 07/11] refactor: only notify users of template --- coderd/database/dbauthz/dbauthz.go | 10 ------- coderd/database/dbmem/dbmem.go | 27 ------------------ coderd/database/dbmetrics/dbmetrics.go | 7 ----- coderd/database/dbmock/dbmock.go | 15 ---------- coderd/database/querier.go | 1 - coderd/database/queries.sql.go | 38 -------------------------- coderd/database/queries/templates.sql | 15 ---------- coderd/templates.go | 13 +++++++-- enterprise/coderd/templates_test.go | 14 +++++----- 9 files changed, 17 insertions(+), 123 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 99c4abcd0d27d..052f25450e6a5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2339,16 +2339,6 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas return q.db.GetUsersByIDs(ctx, ids) } -func (q *querier) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { - // Ensure we have permission to access this template. - _, err := q.GetTemplateByID(ctx, id) - if err != nil { - return nil, err - } - - return q.db.GetUsersWithAccessToTemplateByID(ctx, id) -} - 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 1ac9074006949..24498d88c9dbc 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5669,33 +5669,6 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } -func (q *FakeQuerier) GetUsersWithAccessToTemplateByID(_ context.Context, id uuid.UUID) ([]uuid.UUID, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - groups := make(map[string]struct{}, 0) - for _, template := range q.templates { - if template.ID != id { - continue - } - - for group := range template.GroupACL { - groups[group] = struct{}{} - } - } - - users := make([]uuid.UUID, 0) - for _, member := range q.organizationMembers { - if _, ok := groups[member.OrganizationID.String()]; !ok { - continue - } - - users = append(users, member.UserID) - } - - return users, nil -} - 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 79f5dbb7d7f30..c3e9de22fb0d8 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1348,13 +1348,6 @@ func (m metricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]dat return users, err } -func (m metricsStore) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { - start := time.Now() - r0, r1 := m.s.GetUsersWithAccessToTemplateByID(ctx, id) - m.queryLatencies.WithLabelValues("GetUsersWithAccessToTemplateByID").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 d2a4b5b2df8fe..b3c7b9e7615d3 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2813,21 +2813,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) } -// GetUsersWithAccessToTemplateByID mocks base method. -func (m *MockStore) GetUsersWithAccessToTemplateByID(arg0 context.Context, arg1 uuid.UUID) ([]uuid.UUID, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUsersWithAccessToTemplateByID", arg0, arg1) - ret0, _ := ret[0].([]uuid.UUID) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetUsersWithAccessToTemplateByID indicates an expected call of GetUsersWithAccessToTemplateByID. -func (mr *MockStoreMockRecorder) GetUsersWithAccessToTemplateByID(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersWithAccessToTemplateByID", reflect.TypeOf((*MockStore)(nil).GetUsersWithAccessToTemplateByID), arg0, arg1) -} - // 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/querier.go b/coderd/database/querier.go index 1e8f6909b1d86..fcb58a7d6e305 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -290,7 +290,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) - GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, 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 f0a290d2a2f8b..45cbef3f5e1d8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8557,44 +8557,6 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate return items, nil } -const getUsersWithAccessToTemplateByID = `-- name: GetUsersWithAccessToTemplateByID :many -SELECT - user_id -FROM - organization_members -WHERE - organization_members.organization_id::text IN ( - SELECT - jsonb_object_keys(group_acl) - FROM - templates - WHERE templates.id = $1 - ) -` - -func (q *sqlQuerier) GetUsersWithAccessToTemplateByID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { - rows, err := q.db.QueryContext(ctx, getUsersWithAccessToTemplateByID, id) - if err != nil { - return nil, err - } - defer rows.Close() - var items []uuid.UUID - for rows.Next() { - var user_id uuid.UUID - if err := rows.Scan(&user_id); err != nil { - return nil, err - } - items = append(items, user_id) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const insertTemplate = `-- name: InsertTemplate :exec INSERT INTO templates ( diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index eb270d4d491a6..84df9633a1a53 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -199,18 +199,3 @@ SET WHERE id = $1 ; - --- name: GetUsersWithAccessToTemplateByID :many -SELECT - user_id -FROM - organization_members -WHERE - organization_members.organization_id::text IN ( - SELECT - jsonb_object_keys(group_acl) - FROM - templates - WHERE templates.id = $1 - ) -; diff --git a/coderd/templates.go b/coderd/templates.go index 14dced44a68ae..cbc6eb784d2e4 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -862,14 +862,21 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } func (api *API) notifyUsersOfTemplateDeprecation(ctx context.Context, template database.Template) error { - users, err := api.Database.GetUsersWithAccessToTemplateByID(ctx, template.ID) + workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ + TemplateIDs: []uuid.UUID{template.ID}, + }) if err != nil { - return xerrors.Errorf("get users with access to template by id: %w", err) + return xerrors.Errorf("get workspaces by template id: %w", err) + } + + users := make(map[uuid.UUID]struct{}) + for _, workspace := range workspaces { + users[workspace.OwnerID] = struct{}{} } errs := []error{} - for _, userID := range users { + for userID := range users { _, err = api.NotificationsEnqueuer.Enqueue( //nolint:gocritic // We need the system auth context to be able to send the deprecation notification. dbauthz.AsSystemRestricted(ctx), diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 1d0cb13306fe8..5f981b83bbdb5 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -47,18 +47,18 @@ func TestTemplates(t *testing.T) { }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ - codersdk.FeatureAccessControl: 1, - codersdk.FeatureMultipleOrganizations: 1, + codersdk.FeatureAccessControl: 1, }, }, }) - client, anotherUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + client, secondUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + _, thirdUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - org := coderdenttest.CreateOrganization(t, owner, coderdenttest.CreateOrganizationOptions{}) - _, thirdUser := coderdtest.CreateAnotherUser(t, owner, org.ID, rbac.RoleTemplateAdmin()) + _ = coderdtest.CreateWorkspace(t, owner, template.ID) + _ = coderdtest.CreateWorkspace(t, client, template.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -80,7 +80,7 @@ func TestTemplates(t *testing.T) { } require.Equal(t, 2, len(notifs)) - expectedSentTo := []string{user.UserID.String(), anotherUser.ID.String()} + expectedSentTo := []string{user.UserID.String(), secondUser.ID.String()} slices.Sort(expectedSentTo) sentTo := []string{} @@ -93,7 +93,7 @@ func TestTemplates(t *testing.T) { assert.Equal(t, expectedSentTo, sentTo) // The previous check should verify this but we're double checking that - // the notification wasn't sent to a user in another org. + // the notification wasn't sent to users not using the template. for _, notif := range notifs { assert.NotEqual(t, thirdUser.ID, notif.UserID) } From c63dbff5b760f29d96b087388cbb0a98112c7b99 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 24 Oct 2024 10:39:30 +0000 Subject: [PATCH 08/11] test: create another template+workspace --- enterprise/coderd/templates_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 5f981b83bbdb5..cde01553e349c 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -52,7 +52,8 @@ func TestTemplates(t *testing.T) { }, }) client, secondUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) - _, thirdUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + otherClient, otherUser := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -60,6 +61,14 @@ func TestTemplates(t *testing.T) { _ = coderdtest.CreateWorkspace(t, owner, template.ID) _ = coderdtest.CreateWorkspace(t, client, template.ID) + // Create another template for testing that users of another template do not + // get a notification. + secondVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + secondTemplate := coderdtest.CreateTemplate(t, client, user.OrganizationID, secondVersion.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, secondVersion.ID) + + _ = coderdtest.CreateWorkspace(t, otherClient, secondTemplate.ID) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -95,7 +104,7 @@ func TestTemplates(t *testing.T) { // The previous check should verify this but we're double checking that // the notification wasn't sent to users not using the template. for _, notif := range notifs { - assert.NotEqual(t, thirdUser.ID, notif.UserID) + assert.NotEqual(t, otherUser.ID, notif.UserID) } _, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ From 468ba87954fe00140ecb1f8bc4748234e98b9059 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 24 Oct 2024 10:47:05 +0000 Subject: [PATCH 09/11] fix: remove unused test --- coderd/database/dbauthz/dbauthz_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4032b41526573..6a34e88104ce1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1131,10 +1131,6 @@ func (s *MethodTestSuite) TestUser() { Asserts(a, policy.ActionRead, b, policy.ActionRead). Returns(slice.New(a, b)) })) - s.Run("GetUsersWithAccessToTemplateByID", s.Subtest(func(db database.Store, check *expects) { - a := dbgen.Template(s.T(), db, database.Template{}) - check.Args(a.ID).Asserts(a, policy.ActionRead) - })) s.Run("GetUsers", s.Subtest(func(db database.Store, check *expects) { dbgen.User(s.T(), db, database.User{Username: "GetUsers-a-user"}) dbgen.User(s.T(), db, database.User{Username: "GetUsers-b-user"}) From c1e0fb24482bffc555f571c0f33a32ad4ec6d792 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 24 Oct 2024 11:25:20 +0000 Subject: [PATCH 10/11] chore: rename 'See workspaces' to 'See affected workspaces' --- .../migrations/000270_template_deprecation_notification.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/migrations/000270_template_deprecation_notification.up.sql b/coderd/database/migrations/000270_template_deprecation_notification.up.sql index 1a289f448f5d0..e98f852c8b4e1 100644 --- a/coderd/database/migrations/000270_template_deprecation_notification.up.sql +++ b/coderd/database/migrations/000270_template_deprecation_notification.up.sql @@ -11,7 +11,7 @@ VALUES ( 'Template Events', '[ { - "label": "See workspaces", + "label": "See affected workspaces", "url": "{{base_url}}/workspaces?filter=owner%3Ame+template%3A{{.Labels.template}}" }, { From b210680ef49ef7ea84bd49453d80a297666a01ba Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 24 Oct 2024 11:33:44 +0000 Subject: [PATCH 11/11] fix: update golden files --- .../smtp/TemplateTemplateDeprecated.html.golden | 6 +++--- .../webhook/TemplateTemplateDeprecated.json.golden | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden index b627c6f8aafcf..1393acc4bc60a 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden @@ -20,8 +20,8 @@ New workspaces may not be created from this template. Existing workspaces w= ill continue to function normally. -See workspaces: http://test.com/workspaces?filter=3Downer%3Ame+template%3Aa= -lpha +See affected workspaces: http://test.com/workspaces?filter=3Downer%3Ame+tem= +plate%3Aalpha View template: http://test.com/templates/coder/alpha @@ -69,7 +69,7 @@ s will continue to function normally.

3Aalpha" style=3D"display: inline-block; padding: 13px 24px; background-col= or: #020617; color: #f8fafc; text-decoration: none; border-radius: 8px; mar= gin: 0 4px;"> - See workspaces + See affected workspaces =20